Skip to content

Commit 7d07ed6

Browse files
authored
feat: enable full transition support for the rsc router (#14362)
1 parent fbcc4df commit 7d07ed6

File tree

4 files changed

+284
-54
lines changed

4 files changed

+284
-54
lines changed

.changeset/lucky-tables-itch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"integration": patch
3+
"react-router": patch
4+
---
5+
6+
feat: enable full transition support for the rsc router

integration/rsc/rsc-test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,11 @@ implementations.forEach((implementation) => {
508508
path: "ssr-error",
509509
lazy: () => import("./routes/ssr-error/ssr-error"),
510510
},
511+
{
512+
id: "action-transition-state",
513+
path: "action-transition-state",
514+
lazy: () => import("./routes/action-transition-state/home"),
515+
}
511516
],
512517
},
513518
] satisfies RSCRouteConfig;
@@ -1314,6 +1319,49 @@ implementations.forEach((implementation) => {
13141319
throw new Error("Error from SSR component");
13151320
}
13161321
`,
1322+
1323+
"src/routes/action-transition-state/home.tsx": js`
1324+
import { Suspense } from "react";
1325+
import { IncrementButton } from "./client";
1326+
let count = 0;
1327+
1328+
export default function ActionTransitionState() {
1329+
return (
1330+
<div>
1331+
<form
1332+
action={async () => {
1333+
"use server";
1334+
await new Promise((r) => setTimeout(r, 1000));
1335+
count++;
1336+
}}
1337+
>
1338+
<IncrementButton count={count} />
1339+
</form>
1340+
<Suspense>
1341+
<AsyncComponent count={count} />
1342+
</Suspense>
1343+
</div>
1344+
);
1345+
}
1346+
1347+
async function AsyncComponent({ count }) {
1348+
await new Promise((r) => setTimeout(r, 1000));
1349+
return <div data-testid="async-count">AsyncCount: {count}</div>;
1350+
}
1351+
`,
1352+
"src/routes/action-transition-state/client.tsx": js`
1353+
"use client";
1354+
import { useFormStatus } from "react-dom";
1355+
1356+
export function IncrementButton({ count }: { count: number }) {
1357+
const { pending } = useFormStatus();
1358+
return (
1359+
<button data-testid="increment-button" type="submit" disabled={pending}>
1360+
IncrementCount: {pending ? count + 1 : count}
1361+
</button>
1362+
);
1363+
}
1364+
`,
13171365
},
13181366
});
13191367
});
@@ -1796,6 +1844,44 @@ implementations.forEach((implementation) => {
17961844
const actionResponse = await actionResponsePromise;
17971845
expect(await actionResponse.headerValue("x-test")).toBe("test");
17981846
});
1847+
1848+
test("Supports transition state throughout the revalidation lifecycle", async ({
1849+
page,
1850+
}) => {
1851+
test.skip(
1852+
implementation.name === "parcel",
1853+
"Uses inline server actions which parcel doesn't support yet",
1854+
);
1855+
1856+
await page.goto(`http://localhost:${port}/action-transition-state`, {
1857+
waitUntil: "networkidle",
1858+
});
1859+
1860+
const count0Button = page.getByText("IncrementCount: 0");
1861+
await expect(count0Button).toBeEnabled();
1862+
await count0Button.click();
1863+
1864+
const count1Button = page.getByText("IncrementCount: 1");
1865+
await expect(count1Button).toBeDisabled();
1866+
1867+
expect(await page.getByTestId("async-count").textContent()).toBe(
1868+
"AsyncCount: 0",
1869+
);
1870+
1871+
await page.waitForFunction(
1872+
() =>
1873+
!(
1874+
document.querySelector(
1875+
'[data-testid="increment-button"]',
1876+
) as HTMLButtonElement
1877+
)?.disabled,
1878+
);
1879+
await expect(count1Button).toBeEnabled();
1880+
1881+
await expect(page.getByTestId("async-count")).toHaveText(
1882+
"AsyncCount: 1",
1883+
);
1884+
});
17991885
});
18001886

18011887
test.describe("Errors", () => {

packages/react-router/lib/components.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,143 @@ export interface RouterProviderProps {
291291
unstable_onError?: unstable_ClientOnErrorFunction;
292292
}
293293

294+
function shallowDiff(a: any, b: any) {
295+
if (a === b) {
296+
return false;
297+
}
298+
let aKeys = Object.keys(a);
299+
let bKeys = Object.keys(b);
300+
if (aKeys.length !== bKeys.length) {
301+
return true;
302+
}
303+
for (let key of aKeys) {
304+
if (a[key] !== b[key]) {
305+
return true;
306+
}
307+
}
308+
return false;
309+
}
310+
311+
export function UNSTABLE_TransitionEnabledRouterProvider({
312+
router,
313+
flushSync: reactDomFlushSyncImpl,
314+
unstable_onError,
315+
}: RouterProviderProps) {
316+
let fetcherData = React.useRef<Map<string, any>>(new Map());
317+
let [revalidating, startRevalidation] = React.useTransition();
318+
let [state, setState] = React.useState(router.state);
319+
320+
(router as any).__setPendingRerender = (promise: Promise<() => void>) =>
321+
startRevalidation(
322+
// @ts-expect-error - need react 19 types for this to be async
323+
async () => {
324+
const rerender = await promise;
325+
startRevalidation(() => {
326+
rerender();
327+
});
328+
},
329+
);
330+
331+
let navigator = React.useMemo((): Navigator => {
332+
return {
333+
createHref: router.createHref,
334+
encodeLocation: router.encodeLocation,
335+
go: (n) => router.navigate(n),
336+
push: (to, state, opts) =>
337+
router.navigate(to, {
338+
state,
339+
preventScrollReset: opts?.preventScrollReset,
340+
}),
341+
replace: (to, state, opts) =>
342+
router.navigate(to, {
343+
replace: true,
344+
state,
345+
preventScrollReset: opts?.preventScrollReset,
346+
}),
347+
};
348+
}, [router]);
349+
350+
let basename = router.basename || "/";
351+
352+
let dataRouterContext = React.useMemo(
353+
() => ({
354+
router,
355+
navigator,
356+
static: false,
357+
basename,
358+
unstable_onError,
359+
}),
360+
[router, navigator, basename, unstable_onError],
361+
);
362+
363+
React.useLayoutEffect(() => {
364+
return router.subscribe(
365+
(newState, { deletedFetchers, flushSync, viewTransitionOpts }) => {
366+
newState.fetchers.forEach((fetcher, key) => {
367+
if (fetcher.data !== undefined) {
368+
fetcherData.current.set(key, fetcher.data);
369+
}
370+
});
371+
deletedFetchers.forEach((key) => fetcherData.current.delete(key));
372+
373+
const diff = shallowDiff(state, newState);
374+
375+
if (!diff) return;
376+
377+
if (flushSync) {
378+
if (reactDomFlushSyncImpl) {
379+
reactDomFlushSyncImpl(() => setState(newState));
380+
} else {
381+
setState(newState);
382+
}
383+
} else {
384+
React.startTransition(() => {
385+
setState(newState);
386+
});
387+
}
388+
},
389+
);
390+
}, [router, reactDomFlushSyncImpl, state]);
391+
392+
// The fragment and {null} here are important! We need them to keep React 18's
393+
// useId happy when we are server-rendering since we may have a <script> here
394+
// containing the hydrated server-side staticContext (from StaticRouterProvider).
395+
// useId relies on the component tree structure to generate deterministic id's
396+
// so we need to ensure it remains the same on the client even though
397+
// we don't need the <script> tag
398+
return (
399+
<>
400+
<DataRouterContext.Provider value={dataRouterContext}>
401+
<DataRouterStateContext.Provider
402+
value={{
403+
...state,
404+
revalidation: revalidating ? "loading" : state.revalidation,
405+
}}
406+
>
407+
<FetchersContext.Provider value={fetcherData.current}>
408+
{/* <ViewTransitionContext.Provider value={vtContext}> */}
409+
<Router
410+
basename={basename}
411+
location={state.location}
412+
navigationType={state.historyAction}
413+
navigator={navigator}
414+
>
415+
<MemoizedDataRoutes
416+
routes={router.routes}
417+
future={router.future}
418+
state={state}
419+
unstable_onError={unstable_onError}
420+
/>
421+
</Router>
422+
{/* </ViewTransitionContext.Provider> */}
423+
</FetchersContext.Provider>
424+
</DataRouterStateContext.Provider>
425+
</DataRouterContext.Provider>
426+
{null}
427+
</>
428+
);
429+
}
430+
294431
/**
295432
* Render the UI for the given {@link DataRouter}. This component should
296433
* typically be at the top of an app's element tree.

0 commit comments

Comments
 (0)