diff --git a/docs/src/pages/en/(pages)/framework/http.mdx b/docs/src/pages/en/(pages)/framework/http.mdx index 5fa69df8..0350e640 100644 --- a/docs/src/pages/en/(pages)/framework/http.mdx +++ b/docs/src/pages/en/(pages)/framework/http.mdx @@ -280,6 +280,51 @@ export default function MyComponent() { }; ``` +The `redirect()` function accepts an optional third argument `kind` that controls how the redirect is performed on the client. The available kinds are: + +| Kind | Description | +| --- | --- | +| `"navigate"` | **(default)** Performs an RSC navigation using `replaceState`. The browser URL changes without adding a history entry. | +| `"push"` | Performs an RSC navigation using `pushState`. The browser URL changes and a new history entry is added, so the user can navigate back. | +| `"location"` | Forces a full browser navigation via `location.href`. Useful for redirecting to external URLs or when a full page reload is needed. | +| `"error"` | Throws the redirect error on the client instead of navigating. This allows custom handling via `try`/`catch` in server action calls. | + +```jsx +import { redirect } from "@lazarv/react-server"; + +// RSC navigation with pushState (adds history entry) +redirect("/dashboard", 302, "push"); + +// Full browser navigation +redirect("/oauth/authorize", 302, "location"); + +// Throw on client for custom handling +redirect("/login", 302, "error"); +``` + +When using the `"error"` kind in a server action, the client can catch the redirect error and handle it: + +```jsx +"use client"; + +import { myServerAction } from "./actions"; + +export function MyComponent() { + const handleClick = async () => { + try { + await myServerAction(); + } catch (e) { + if (e?.digest?.startsWith("Location=")) { + const url = e.digest.split("Location=")[1]?.split(";")[0]; + console.log(`Redirect to: ${url}`); + } + } + }; + + return ; +} +``` + ## Rewrite diff --git a/docs/src/pages/en/(pages)/router/server-routing.mdx b/docs/src/pages/en/(pages)/router/server-routing.mdx index aeafdc71..6f5f9fa0 100644 --- a/docs/src/pages/en/(pages)/router/server-routing.mdx +++ b/docs/src/pages/en/(pages)/router/server-routing.mdx @@ -192,6 +192,19 @@ export default function App() { } ``` +You can also specify a `kind` parameter to control how the redirect behaves on the client: + +```tsx +import { redirect } from "@lazarv/react-server"; + +export default function ProtectedPage() { + // Redirect with pushState so the user can navigate back + redirect("/login", 302, "push"); +} +``` + +Available redirect kinds: `"navigate"` (default, replaceState), `"push"` (pushState), `"location"` (full browser navigation), and `"error"` (throw on client for custom handling). See the [HTTP redirect documentation](/framework/http#redirect) for details. + ## Rewrites diff --git a/docs/src/pages/ja/(pages)/framework/http.mdx b/docs/src/pages/ja/(pages)/framework/http.mdx index 984665bc..cb7129ca 100644 --- a/docs/src/pages/ja/(pages)/framework/http.mdx +++ b/docs/src/pages/ja/(pages)/framework/http.mdx @@ -279,6 +279,51 @@ export default function MyComponent() { }; ``` +`redirect()` 関数は、クライアントでのリダイレクトの動作を制御するオプションの第3引数 `kind` を受け付けます。利用可能な種類は以下の通りです: + +| 種類 | 説明 | +| --- | --- | +| `"navigate"` | **(デフォルト)** `replaceState` を使用したRSCナビゲーションを実行します。ブラウザのURLは変更されますが、履歴エントリは追加されません。 | +| `"push"` | `pushState` を使用したRSCナビゲーションを実行します。ブラウザのURLが変更され、新しい履歴エントリが追加されるため、ユーザーは戻るボタンで戻ることができます。 | +| `"location"` | `location.href` を使用した完全なブラウザナビゲーションを強制します。外部URLへのリダイレクトやページの完全なリロードが必要な場合に便利です。 | +| `"error"` | ナビゲーションの代わりにクライアントでリダイレクトエラーをスローします。サーバーアクション呼び出しで `try`/`catch` によるカスタム処理が可能になります。 | + +```jsx +import { redirect } from "@lazarv/react-server"; + +// pushStateを使用したRSCナビゲーション(履歴エントリを追加) +redirect("/dashboard", 302, "push"); + +// 完全なブラウザナビゲーション +redirect("/oauth/authorize", 302, "location"); + +// カスタム処理のためにクライアントでスロー +redirect("/login", 302, "error"); +``` + +サーバーアクションで `"error"` 種類を使用する場合、クライアントでリダイレクトエラーをキャッチして処理できます: + +```jsx +"use client"; + +import { myServerAction } from "./actions"; + +export function MyComponent() { + const handleClick = async () => { + try { + await myServerAction(); + } catch (e) { + if (e?.digest?.startsWith("Location=")) { + const url = e.digest.split("Location=")[1]?.split(";")[0]; + console.log(`リダイレクト先: ${url}`); + } + } + }; + + return ; +} +``` + ## リライト diff --git a/docs/src/pages/ja/(pages)/router/server-routing.mdx b/docs/src/pages/ja/(pages)/router/server-routing.mdx index d53a405d..1bcfe71f 100644 --- a/docs/src/pages/ja/(pages)/router/server-routing.mdx +++ b/docs/src/pages/ja/(pages)/router/server-routing.mdx @@ -192,6 +192,19 @@ export default function App() { } ``` +`kind` パラメータを指定して、クライアントでのリダイレクトの動作を制御することもできます: + +```tsx +import { redirect } from "@lazarv/react-server"; + +export default function ProtectedPage() { + // pushStateを使用してリダイレクト(ユーザーが戻れるように) + redirect("/login", 302, "push"); +} +``` + +利用可能なリダイレクト種類:`"navigate"`(デフォルト、replaceState)、`"push"`(pushState)、`"location"`(完全なブラウザナビゲーション)、`"error"`(カスタム処理のためにクライアントでスロー)。詳細は[HTTPリダイレクトのドキュメント](/framework/http#redirect)を参照してください。 + ## リライト diff --git a/examples/file-router/components/redirect-kind-buttons.tsx b/examples/file-router/components/redirect-kind-buttons.tsx new file mode 100644 index 00000000..b688988c --- /dev/null +++ b/examples/file-router/components/redirect-kind-buttons.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; + +import { + redirectNavigate, + redirectPush, + redirectLocation, + redirectLocationExternal, + redirectError, +} from "../redirect-actions"; + +export default function RedirectKindButtons() { + const [result, setResult] = useState(null); + + return ( +
+ +
+ +
+ +
+ +
+ + {result &&

{result}

} +
+ ); +} diff --git a/examples/file-router/pages/(redirect_extern).middleware.ts b/examples/file-router/pages/(redirect_extern).middleware.ts index d73afb47..d143effe 100644 --- a/examples/file-router/pages/(redirect_extern).middleware.ts +++ b/examples/file-router/pages/(redirect_extern).middleware.ts @@ -16,4 +16,17 @@ export default function RedirectMiddleware() { if (pathname === "/redirect-about") { redirect("/about"); } + // Redirect kind examples + if (pathname === "/redirect-push") { + redirect("/about", 302, "push"); + } + if (pathname === "/redirect-location") { + redirect("/about", 302, "location"); + } + if (pathname === "/redirect-location-external") { + redirect("https://react-server.dev", 302, "location"); + } + if (pathname === "/redirect-error") { + redirect("/about", 302, "error"); + } } diff --git a/examples/file-router/pages/index.tsx b/examples/file-router/pages/index.tsx index e2d55bc0..13f037bc 100644 --- a/examples/file-router/pages/index.tsx +++ b/examples/file-router/pages/index.tsx @@ -22,6 +22,18 @@ export default function IndexPage() { External with API
Internal redirect to existing about page +

Redirect Kind:

+ Redirect Kind (server actions) +
+ Push (middleware) +
+ Location (middleware) +
+ + Location External (middleware) + +
+ Error (middleware)

Error:

Throw error in middleware diff --git a/examples/file-router/pages/redirect-kind/index.tsx b/examples/file-router/pages/redirect-kind/index.tsx new file mode 100644 index 00000000..da97215a --- /dev/null +++ b/examples/file-router/pages/redirect-kind/index.tsx @@ -0,0 +1,11 @@ +import RedirectKindButtons from "../../components/redirect-kind-buttons"; + +export default function RedirectKindPage() { + return ( +
+

Redirect Kind

+

Test different redirect kinds via server actions:

+ +
+ ); +} diff --git a/examples/file-router/redirect-actions.ts b/examples/file-router/redirect-actions.ts new file mode 100644 index 00000000..42e57a8e --- /dev/null +++ b/examples/file-router/redirect-actions.ts @@ -0,0 +1,23 @@ +"use server"; + +import { redirect } from "@lazarv/react-server"; + +export async function redirectNavigate() { + redirect("/about", 302, "navigate"); +} + +export async function redirectPush() { + redirect("/about", 302, "push"); +} + +export async function redirectLocation() { + redirect("/about", 302, "location"); +} + +export async function redirectLocationExternal() { + redirect("https://react-server.dev", 302, "location"); +} + +export async function redirectError() { + redirect("/about", 302, "error"); +} diff --git a/examples/file-router/tsconfig.json b/examples/file-router/tsconfig.json new file mode 100644 index 00000000..c9052503 --- /dev/null +++ b/examples/file-router/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "strict": true, + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/*.js", "**/*.mjs", "node_modules"] +} diff --git a/packages/react-server/client/ClientProvider.jsx b/packages/react-server/client/ClientProvider.jsx index a0f65dfa..e0464b27 100644 --- a/packages/react-server/client/ClientProvider.jsx +++ b/packages/react-server/client/ClientProvider.jsx @@ -525,19 +525,40 @@ export const streamOptions = ({ resolve(typeof value === "undefined" ? args[0] : value); } catch (e) { - const location = e?.digest?.startsWith("Location=") - ? e.digest.slice(9) - : res.headers.get("Location"); - if (location) { + let redirectLocation = null; + let redirectKind = "navigate"; + if (e?.digest?.startsWith("Location=")) { + const digestValue = e.digest.slice(9); + const semicolonIndex = digestValue.indexOf(";"); + if (semicolonIndex !== -1) { + redirectLocation = digestValue.slice(0, semicolonIndex); + const kindMatch = digestValue + .slice(semicolonIndex) + .match(/kind=([^;]+)/); + redirectKind = kindMatch?.[1] || "navigate"; + } else { + redirectLocation = digestValue; + } + } else { + redirectLocation = res.headers.get("Location"); + } + if (redirectLocation) { + if (redirectKind === "error") { + return reject(e); + } + if (redirectKind === "location") { + location.href = redirectLocation; + return resolve(args[0]); + } const value = rsc.slice(0, -1); - flightCache.set(`${outlet}:${location}`, value); + flightCache.set(`${outlet}:${redirectLocation}`, value); flightCache.set( - `${outlet}:${location}:timestamp`, + `${outlet}:${redirectLocation}:timestamp`, Date.now() ); - navigate(location, { + navigate(redirectLocation, { outlet, - replace: true, + push: redirectKind === "push", fromCache: true, }); return resolve(args[0]); @@ -727,6 +748,7 @@ function getFlightResponse(url, options = {}) { let chunks = 0; let redirectTo = null; + let redirectKind = "navigate"; const reader = body.getReader(); abortController?.signal?.addEventListener( @@ -743,9 +765,13 @@ function getFlightResponse(url, options = {}) { if (value) { if (!redirectTo) { const decodedValue = decoder.decode(value); - redirectTo = decodedValue.match( - /1:E\{"digest":"Location=(?[^"]+)"/ - )?.groups.location; + const redirectMatch = decodedValue.match( + /\d+:E\{"digest":"Location=(?[^;]+)(?:;kind=(?[^"]+))?"/ + ); + if (redirectMatch?.groups.location) { + redirectTo = redirectMatch.groups.location; + redirectKind = redirectMatch.groups.kind || "navigate"; + } } controller.enqueue(value); @@ -768,17 +794,24 @@ function getFlightResponse(url, options = {}) { controller.close(); - if (redirectTo) { - const url = new URL(redirectTo, location.origin); - - if (url.origin === location.origin) { - navigate(redirectTo, { - outlet: options.outlet, - external: options.outlet !== PAGE_ROOT, - push: false, - }); + if (redirectTo && !options.callServer) { + if (redirectKind === "error") { + // Don't auto-redirect; the error will propagate through React's error boundary + // and can be caught via try/catch when calling server actions directly + } else if (redirectKind === "location") { + location.href = redirectTo; } else { - location.replace(redirectTo); + const url = new URL(redirectTo, location.origin); + + if (url.origin === location.origin) { + navigate(redirectTo, { + outlet: options.outlet, + external: options.outlet !== PAGE_ROOT, + push: redirectKind === "push", + }); + } else { + location.replace(redirectTo); + } } } diff --git a/packages/react-server/client/ErrorBoundary.jsx b/packages/react-server/client/ErrorBoundary.jsx index 40bee91e..11efaf7f 100644 --- a/packages/react-server/client/ErrorBoundary.jsx +++ b/packages/react-server/client/ErrorBoundary.jsx @@ -128,7 +128,18 @@ export class ErrorBoundary extends Component { const { didCatch, error } = this.state; if (error?.digest.startsWith("Location=")) { - error.redirectTo = error.digest.slice(9); + const digestValue = error.digest.slice(9); + const semicolonIndex = digestValue.indexOf(";"); + if (semicolonIndex !== -1) { + error.redirectTo = digestValue.slice(0, semicolonIndex); + const kindMatch = digestValue + .slice(semicolonIndex) + .match(/kind=([^;]+)/); + error.redirectKind = kindMatch?.[1] || "navigate"; + } else { + error.redirectTo = digestValue; + error.redirectKind = "navigate"; + } } let childToRender = children; @@ -189,24 +200,32 @@ function FallbackRenderComponent({ const client = useClient(); const { navigate } = client; const { error } = props; - const { redirectTo } = error; + const { redirectTo, redirectKind } = error; useEffect(() => { if (redirectTo) { + if (redirectKind === "error") { + // Don't auto-redirect; let the error boundary display the error + return; + } + if (redirectKind === "location") { + location.href = redirectTo; + return; + } const url = new URL(redirectTo, location.origin); if (url.origin === location.origin) { navigate(redirectTo, { outlet, external: outlet !== PAGE_ROOT, - push: false, + push: redirectKind === "push", }); } else { location.replace(redirectTo); } } - }, [redirectTo, navigate, outlet]); + }, [redirectTo, redirectKind, navigate, outlet]); - if (redirectTo) { + if (redirectTo && redirectKind !== "error") { return null; } diff --git a/packages/react-server/server/index.d.ts b/packages/react-server/server/index.d.ts index 147a3560..6630d292 100644 --- a/packages/react-server/server/index.d.ts +++ b/packages/react-server/server/index.d.ts @@ -48,11 +48,22 @@ export function withCache( */ export function useResponseCache(ttl?: number): void; +/** + * The kind of redirect to perform on the client. + * + * - `navigate` - RSC client-side navigation using replaceState (default) + * - `push` - RSC client-side navigation using pushState + * - `location` - Full browser navigation via window.location + * - `error` - Throw an error on the client instead of redirecting, allowing custom handling via try/catch + */ +export type RedirectKind = "navigate" | "push" | "location" | "error"; + /** * Redirects the request to the specified URL. * * @param url - The URL to redirect to * @param status - The status code to use for the redirect + * @param kind - The kind of redirect to perform on the client * * @example * @@ -64,11 +75,18 @@ export function useResponseCache(ttl?: number): void; * redirect('/login'); * } * + * // OAuth flow - force full browser navigation + * redirect('/api/oauth/authorize', 302, 'location'); + * * // ... * } * ``` */ -export function redirect(url: string, status?: number): void; +export function redirect( + url: string, + status?: number, + kind?: RedirectKind +): void; /** * This hook returns the current request context, including the request, response, URL and method. diff --git a/packages/react-server/server/redirects.mjs b/packages/react-server/server/redirects.mjs index 21285597..72960f6c 100644 --- a/packages/react-server/server/redirects.mjs +++ b/packages/react-server/server/redirects.mjs @@ -10,15 +10,16 @@ import { usePostpone } from "./postpone.mjs"; import { RENDER_TYPE } from "./render-context.mjs"; export class RedirectError extends Error { - constructor(url, status) { + constructor(url, status, kind = "navigate") { super("Redirect"); this.url = url; this.status = status; + this.kind = kind; this.digest = `${status} ${url}`; } } -export function redirect(url, status = 302) { +export function redirect(url, status = 302, kind = "navigate") { usePostpone(dynamicHookError("redirect")); const store = getContext(REDIRECT_CONTEXT); @@ -29,7 +30,7 @@ export function redirect(url, status = 302) { const renderContext = getContext(RENDER_CONTEXT); if (renderContext?.type === RENDER_TYPE.RSC) { store.response = new Response( - `0:["$L1"]\n1:E{"digest":"Location=${url}","message":"REDIRECT","env":"server","stack":[],"owner":null}\n`, + `0:["$L1"]\n1:E{"digest":"Location=${url};kind=${kind}","message":"REDIRECT","env":"server","stack":[],"owner":null}\n`, { status: 200, headers: { @@ -60,5 +61,5 @@ export function redirect(url, status = 302) { } } - throw new RedirectError(url, status); + throw new RedirectError(url, status, kind); } diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index 085292d0..b85591e5 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -520,7 +520,10 @@ export async function render(Component, props = {}, options = {}) { hasError = true; const redirect = getContext(REDIRECT_CONTEXT); if (redirect?.response) { - return `Location=${redirect.response.headers.get("location")}`; + const location = + redirect.response.headers.get("location"); + const kind = e?.kind || "navigate"; + return `Location=${location};kind=${kind}`; } if (import.meta.env.PROD) { logger?.error(e); diff --git a/test/__test__/apps/file-router.spec.mjs b/test/__test__/apps/file-router.spec.mjs index c0adc3ad..6b29caf1 100644 --- a/test/__test__/apps/file-router.spec.mjs +++ b/test/__test__/apps/file-router.spec.mjs @@ -193,4 +193,152 @@ describe("file-router plugin", () => { ); } }); + + test("redirect kind: navigate (server action)", async () => { + await page.goto(`${hostname}/redirect-kind`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Redirect Kind"); + await waitForHydration(); + const btn = await page.$('[data-testid="redirect-navigate"]'); + await waitForBodyUpdate(async () => { + await btn.click(); + }); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + }); + + test("redirect kind: push (server action)", async () => { + await page.goto(`${hostname}/redirect-kind`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Redirect Kind"); + await waitForHydration(); + const btn = await page.$('[data-testid="redirect-push"]'); + await waitForBodyUpdate(async () => { + await btn.click(); + }); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + // push kind adds a history entry, going back should return to redirect-kind page + const prevUrl = page.url(); + await page.goBack(); + await waitForChange(null, () => page.url(), prevUrl); + expect(page.url()).toBe(`${hostname}/redirect-kind`); + await waitForBodyUpdate(); + expect(await page.textContent("body")).toContain("Redirect Kind"); + }); + + test("redirect kind: location (server action, same-origin)", async () => { + await page.goto(`${hostname}/redirect-kind`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Redirect Kind"); + await waitForHydration(); + const btn = await page.$('[data-testid="redirect-location"]'); + await btn.click(); + // location kind forces a full browser navigation via location.href + await page.waitForURL(`${hostname}/about`); + await page.waitForLoadState("load"); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + }); + + test("redirect kind: location (server action, external)", async () => { + await page.goto(`${hostname}/redirect-kind`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Redirect Kind"); + await waitForHydration(); + const btn = await page.$('[data-testid="redirect-location-external"]'); + await btn.click(); + const deadline = Date.now() + 30000; + while (!page.url().includes("react-server.dev")) { + if (Date.now() > deadline) { + throw new Error( + "Timed out waiting for external redirect via location kind" + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(page.url()).toBe("https://react-server.dev/"); + }); + + test("redirect kind: error (server action, try/catch)", async () => { + await page.goto(`${hostname}/redirect-kind`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Redirect Kind"); + await waitForHydration(); + const btn = await page.$('[data-testid="redirect-error"]'); + await waitForBodyUpdate(async () => { + await btn.click(); + }); + // error kind should NOT navigate away — client catches the error + expect(page.url()).toBe(`${hostname}/redirect-kind`); + const resultEl = await page.$('[data-testid="redirect-error-result"]'); + expect(resultEl).not.toBeNull(); + const resultText = await resultEl.textContent(); + expect(resultText).toContain("Caught redirect to: /about"); + }); + + test("redirect kind: push (middleware)", async () => { + await page.goto(`${hostname}/`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain( + "Welcome to the File Router Example" + ); + await waitForHydration(); + const linkToPush = await page.$('a[href="/redirect-push"]'); + await waitForBodyUpdate(async () => { + await linkToPush.click(); + }); + await page.waitForLoadState("load"); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + }); + + test("redirect kind: location (middleware, same-origin)", async () => { + await page.goto(`${hostname}/`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain( + "Welcome to the File Router Example" + ); + await waitForHydration(); + const linkToLocation = await page.$('a[href="/redirect-location"]'); + await linkToLocation.click(); + // location kind forces a full browser navigation via location.href + await page.waitForURL(`${hostname}/about`); + await page.waitForLoadState("load"); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + }); + + test("redirect kind: location (middleware, external)", async () => { + await page.goto(`${hostname}/`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain( + "Welcome to the File Router Example" + ); + await waitForHydration(); + const linkToLocationExternal = await page.$( + 'a[href="/redirect-location-external"]' + ); + await linkToLocationExternal.click(); + const deadline = Date.now() + 30000; + while (!page.url().includes("react-server.dev")) { + if (Date.now() > deadline) { + throw new Error( + "Timed out waiting for external redirect via location kind" + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(page.url()).toBe("https://react-server.dev/"); + }); + + test("redirect kind: error (middleware)", async () => { + // For middleware redirects, the "error" kind behaves the same as "navigate" + // because the redirect is handled at the server level before reaching the client. + // The "error" kind is only meaningful for server actions (try/catch on client). + await page.goto(`${hostname}/redirect-error`); + await page.waitForLoadState("load"); + expect(page.url()).toBe(`${hostname}/about`); + expect(await page.textContent("body")).toContain("About"); + }); });