Skip to content

fix: route action body is no-longer used #13773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 14, 2025
Merged
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
10 changes: 10 additions & 0 deletions integration/helpers/rsc-parcel/src/entry.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,13 @@ createFromReadableStream(getServerStream(), { assets: "manifest" }).then(
});
}
);

if (process.env.NODE_ENV !== "production") {
const ogError = console.error.bind(console);
console.error = (...args) => {
if (args[1] === Symbol.for("react-router.redirect")) {
return;
}
ogError(...args);
};
}
49 changes: 38 additions & 11 deletions integration/rsc/rsc-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,10 @@ implementations.forEach((implementation) => {
validateRSCHtml(await page.content());
});

test("Supports resource routes as URL and fetchers", async ({ page }) => {
test("Supports resource routes as URL and fetchers", async ({
page,
request,
}) => {
let port = await getPort();
stop = await setupRscTest({
implementation,
Expand All @@ -481,19 +484,24 @@ implementations.forEach((implementation) => {
index: true,
lazy: () => import("./routes/home"),
},
{
id: "resource",
path: "resource",
lazy: () => import("./routes/resource"),
},
],
},
{
id: "resource",
path: "resource",
lazy: () => import("./routes/resource"),
},
] satisfies ServerRouteObject[];
`,
"src/routes/resource.tsx": js`
export function loader() {
return Response.json({ message: "Hello from resource route!" });
}

export async function action({ request }) {
return {
message: "Hello from resource route!",
echo: await request.text(),
};
}
`,
Expand All @@ -506,7 +514,7 @@ implementations.forEach((implementation) => {
const fetcher = useFetcher();

const loadResource = () => {
fetcher.load("/resource");
fetcher.submit({ hello: "world" }, { method: "post", action: "/resource" });
};

return (
Expand All @@ -532,19 +540,38 @@ implementations.forEach((implementation) => {
`,
},
});
const response = await page.goto(`http://localhost:${port}/resource`);
expect(response?.status()).toBe(200);
expect(await response?.json()).toEqual({
const getResponse = await request.get(
`http://localhost:${port}/resource`
);
expect(getResponse?.status()).toBe(200);
expect(await getResponse?.json()).toEqual({
message: "Hello from resource route!",
});

const postResponse = await request.post(
`http://localhost:${port}/resource`,
{
data: { hello: "world" },
}
);
expect(postResponse?.status()).toBe(200);
expect(await postResponse?.json()).toEqual({
message: "Hello from resource route!",
echo: JSON.stringify({ hello: "world" }),
});

await page.goto(`http://localhost:${port}/`);
await page.click("button");

await page.waitForSelector("[data-testid=resource-data]");
expect(
await page.locator("[data-testid=resource-data]").textContent()
).toBe(JSON.stringify({ message: "Hello from resource route!" }));
).toBe(
JSON.stringify({
message: "Hello from resource route!",
echo: "hello=world",
})
);
});
});

Expand Down
47 changes: 23 additions & 24 deletions packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ async function processServerAction(
signal: request.signal,
});

const isFormRequest = canDecodeWithFormData(
request.headers.get("Content-Type")
);
const actionId = request.headers.get("rsc-action-id");
if (actionId) {
if (!decodeCallServer) {
Expand All @@ -305,7 +308,7 @@ async function processServerAction(
);
}

const reply = canDecodeWithFormData(request.headers.get("Content-Type"))
const reply = isFormRequest
? await request.formData()
: await request.text();
const serverAction = await decodeCallServer(actionId, reply);
Expand All @@ -325,32 +328,28 @@ async function processServerAction(
actionResult,
revalidationRequest: getRevalidationRequest(),
};
}

const clone = request.clone();
const formData = await request.formData();
if (Array.from(formData.keys()).some((k) => k.startsWith("$ACTION_"))) {
if (!decodeFormAction) {
throw new Error(
"Cannot handle form actions without a decodeFormAction function"
);
}
const action = await decodeFormAction(formData);
try {
await action();
} catch (error) {
if (isResponse(error)) {
return error;
} else if (isFormRequest) {
const formData = await request.clone().formData();
if (Array.from(formData.keys()).some((k) => k.startsWith("$ACTION_"))) {
if (!decodeFormAction) {
throw new Error(
"Cannot handle form actions without a decodeFormAction function"
);
}
onError?.(error);
const action = await decodeFormAction(formData);
try {
await action();
} catch (error) {
if (isResponse(error)) {
return error;
}
onError?.(error);
}
return {
revalidationRequest: getRevalidationRequest(),
};
}
return {
revalidationRequest: getRevalidationRequest(),
};
}
return {
revalidationRequest: clone,
};
}

async function generateResourceResponse(
Expand Down
10 changes: 10 additions & 0 deletions playground/rsc-parcel/src/entry.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@ createFromReadableStream(getServerStream(), { assets: "manifest" }).then(
});
}
);

if (process.env.NODE_ENV !== "production") {
const ogError = console.error.bind(console);
console.error = (...args) => {
if (args[1] === Symbol.for("react-router.redirect")) {
return;
}
ogError(...args);
};
}
13 changes: 10 additions & 3 deletions playground/rsc-parcel/src/routes/fetcher/fetcher.client.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
"use client";

import { useFetcher } from "react-router";
import type { loader } from "../resource/resource";
import type { action } from "../resource/resource";

export default function FetcherRoute() {
const fetcher = useFetcher<typeof loader>();
const fetcher = useFetcher<typeof action>();

return (
<main>
<button onClick={() => fetcher.load("/resource")}>
<button
onClick={() =>
fetcher.submit(
{ hello: "world" },
{ action: "/resource", method: "post", encType: "application/json" }
)
}
>
Load fetcher data
</button>
<pre>{JSON.stringify(fetcher.data, null, 2)}</pre>
Expand Down
7 changes: 7 additions & 0 deletions playground/rsc-parcel/src/routes/home/home.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use server";

import { redirect } from "react-router/rsc";

export function redirectAction(formData: FormData) {
throw redirect("/?redirected=true");
}
6 changes: 6 additions & 0 deletions playground/rsc-parcel/src/routes/home/home.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type ClientLoaderFunctionArgs, useLoaderData } from "react-router";
import { Counter } from "../../counter";

import type { loader } from "./home";
import { redirectAction } from "./home.actions";

export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
const res = await serverLoader<typeof loader>();
Expand All @@ -23,6 +24,11 @@ export default function HomeRoute() {
<h1>{message}</h1>
<p>Did client loader run? {client ? "Yes" : "No"}</p>
<Counter />
<form action={redirectAction as any}>
<button type="submit" data-submit>
Redirect via Server Function
</button>
</form>
</main>
);
}
5 changes: 4 additions & 1 deletion playground/rsc-parcel/src/routes/resource/resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export function loader() {
import type { ActionFunctionArgs } from "react-router";

export async function action({ request }: ActionFunctionArgs) {
return {
timestamp: Date.now(),
message: "Hello from resource route!",
echo: await request.text(),
};
}
Loading