diff --git a/.changeset/lucky-tables-itch.md b/.changeset/lucky-tables-itch.md
new file mode 100644
index 0000000000..63b1775b50
--- /dev/null
+++ b/.changeset/lucky-tables-itch.md
@@ -0,0 +1,6 @@
+---
+"integration": patch
+"react-router": patch
+---
+
+feat: enable full transition support for the rsc router
diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts
index 3c49b46aff..7ddafe7421 100644
--- a/integration/rsc/rsc-test.ts
+++ b/integration/rsc/rsc-test.ts
@@ -508,6 +508,11 @@ implementations.forEach((implementation) => {
path: "ssr-error",
lazy: () => import("./routes/ssr-error/ssr-error"),
},
+ {
+ id: "action-transition-state",
+ path: "action-transition-state",
+ lazy: () => import("./routes/action-transition-state/home"),
+ }
],
},
] satisfies RSCRouteConfig;
@@ -1314,6 +1319,49 @@ implementations.forEach((implementation) => {
throw new Error("Error from SSR component");
}
`,
+
+ "src/routes/action-transition-state/home.tsx": js`
+ import { Suspense } from "react";
+ import { IncrementButton } from "./client";
+ let count = 0;
+
+ export default function ActionTransitionState() {
+ return (
+
+ );
+ }
+
+ async function AsyncComponent({ count }) {
+ await new Promise((r) => setTimeout(r, 1000));
+ return AsyncCount: {count}
;
+ }
+ `,
+ "src/routes/action-transition-state/client.tsx": js`
+ "use client";
+ import { useFormStatus } from "react-dom";
+
+ export function IncrementButton({ count }: { count: number }) {
+ const { pending } = useFormStatus();
+ return (
+
+ );
+ }
+ `,
},
});
});
@@ -1796,6 +1844,44 @@ implementations.forEach((implementation) => {
const actionResponse = await actionResponsePromise;
expect(await actionResponse.headerValue("x-test")).toBe("test");
});
+
+ test("Supports transition state throughout the revalidation lifecycle", async ({
+ page,
+ }) => {
+ test.skip(
+ implementation.name === "parcel",
+ "Uses inline server actions which parcel doesn't support yet",
+ );
+
+ await page.goto(`http://localhost:${port}/action-transition-state`, {
+ waitUntil: "networkidle",
+ });
+
+ const count0Button = page.getByText("IncrementCount: 0");
+ await expect(count0Button).toBeEnabled();
+ await count0Button.click();
+
+ const count1Button = page.getByText("IncrementCount: 1");
+ await expect(count1Button).toBeDisabled();
+
+ expect(await page.getByTestId("async-count").textContent()).toBe(
+ "AsyncCount: 0",
+ );
+
+ await page.waitForFunction(
+ () =>
+ !(
+ document.querySelector(
+ '[data-testid="increment-button"]',
+ ) as HTMLButtonElement
+ )?.disabled,
+ );
+ await expect(count1Button).toBeEnabled();
+
+ await expect(page.getByTestId("async-count")).toHaveText(
+ "AsyncCount: 1",
+ );
+ });
});
test.describe("Errors", () => {
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index c903ca829a..ae4613cb13 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -291,6 +291,143 @@ export interface RouterProviderProps {
unstable_onError?: unstable_ClientOnErrorFunction;
}
+function shallowDiff(a: any, b: any) {
+ if (a === b) {
+ return false;
+ }
+ let aKeys = Object.keys(a);
+ let bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) {
+ return true;
+ }
+ for (let key of aKeys) {
+ if (a[key] !== b[key]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function UNSTABLE_TransitionEnabledRouterProvider({
+ router,
+ flushSync: reactDomFlushSyncImpl,
+ unstable_onError,
+}: RouterProviderProps) {
+ let fetcherData = React.useRef