Skip to content

Commit 1bada66

Browse files
authored
fix: allow JSX to be passed full circle with temporary reference sets (#13898)
1 parent 705a6c6 commit 1bada66

File tree

19 files changed

+246
-95
lines changed

19 files changed

+246
-95
lines changed

docs/start/rsc/route-module.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default function MyRouteComponent() {
4747
}
4848
```
4949

50-
You can also export it as `Component` if that's your think.
50+
You can also export it as `Component` if that's your thing.
5151

5252
### Props passed to the Component
5353

integration/helpers/rsc-parcel-framework/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"@types/react-dom": "^19.0.3",
2929
"@types/react": "^19.0.8",
3030
"parcel": "2.15.0",
31-
"parcel-config-react-router-experimental": "1.0.24",
31+
"parcel-config-react-router-experimental": "1.0.25",
3232
"typescript": "^5.1.6"
3333
},
3434
"dependencies": {

integration/helpers/rsc-parcel/src/browser.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "react-router";
1111
import {
1212
createFromReadableStream,
13+
createTemporaryReferenceSet,
1314
encodeReply,
1415
setServerCallback,
1516
// @ts-expect-error - no types for this yet
@@ -19,6 +20,7 @@ import {
1920
setServerCallback(
2021
createCallServer({
2122
createFromReadableStream,
23+
createTemporaryReferenceSet,
2224
encodeReply,
2325
})
2426
);

integration/helpers/rsc-parcel/src/server.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createRequestListener } from "@mjackson/node-fetch-server";
22
import express from "express";
33
import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router";
44
import {
5+
createTemporaryReferenceSet,
56
decodeAction,
67
decodeFormState,
78
decodeReply,
@@ -18,6 +19,7 @@ import { assets } from "./parcel-entry-wrapper"
1819
function fetchServer(request: Request) {
1920
return matchRSCServerRequest({
2021
// Provide the React Server touchpoints.
22+
createTemporaryReferenceSet,
2123
decodeReply,
2224
decodeAction,
2325
decodeFormState,
@@ -27,8 +29,8 @@ function fetchServer(request: Request) {
2729
// The app routes.
2830
routes,
2931
// Encode the match with the React Server implementation.
30-
generateResponse(match) {
31-
return new Response(renderToReadableStream(match.payload), {
32+
generateResponse(match, options) {
33+
return new Response(renderToReadableStream(match.payload, options), {
3234
status: match.statusCode,
3335
headers: match.headers,
3436
});

integration/helpers/rsc-vite/src/entry.browser.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { startTransition, StrictMode } from "react";
22
import { hydrateRoot } from "react-dom/client";
33
import {
44
createFromReadableStream,
5+
createTemporaryReferenceSet,
56
encodeReply,
67
setServerCallback,
78
} from "@hiogawa/vite-rsc/browser";
@@ -15,6 +16,7 @@ import type { unstable_RSCPayload as RSCPayload } from "react-router";
1516
setServerCallback(
1617
createCallServer({
1718
createFromReadableStream,
19+
createTemporaryReferenceSet,
1820
encodeReply,
1921
})
2022
);

integration/helpers/rsc-vite/src/entry.rsc.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
createTemporaryReferenceSet,
23
decodeAction,
34
decodeReply,
45
loadServerAction,
@@ -10,13 +11,14 @@ import { routes } from "./routes";
1011

1112
export async function fetchServer(request: Request) {
1213
return await matchRSCServerRequest({
14+
createTemporaryReferenceSet,
1315
decodeReply,
1416
decodeAction,
1517
loadServerAction,
1618
request,
1719
routes,
18-
generateResponse(match) {
19-
return new Response(renderToReadableStream(match.payload), {
20+
generateResponse(match, options) {
21+
return new Response(renderToReadableStream(match.payload, options), {
2022
status: match.statusCode,
2123
headers: match.headers,
2224
});

integration/rsc/rsc-test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,74 @@ implementations.forEach((implementation) => {
869869
// Ensure this is using RSC
870870
validateRSCHtml(await page.content());
871871
});
872+
873+
test("Supports React Server Function References", async ({ page }) => {
874+
let port = await getPort();
875+
stop = await setupRscTest({
876+
implementation,
877+
port,
878+
files: {
879+
"src/routes/home.actions.ts": js`
880+
"use server";
881+
882+
export async function incrementCounter({count, ref}: {count: number; ref: unknown}, formData: FormData) {
883+
return {count: count + parseInt(formData.get("by") as string || "1", 10), ref};
884+
}
885+
`,
886+
"src/routes/home.tsx": js`
887+
export { default } from "./home.client";
888+
`,
889+
"src/routes/home.client.tsx": js`
890+
"use client";
891+
892+
import { useActionState } from "react";
893+
894+
import { incrementCounter } from "./home.actions";
895+
896+
const ogRef = {};
897+
export default function HomeRoute() {
898+
const [{count,ref}, incrementCounterAction, incrementing] = useActionState(incrementCounter, {count: 0, ref: ogRef});
899+
900+
return (
901+
<div>
902+
<h2 data-home>Home: ({count})</h2>
903+
<h2 data-home-ref>{ref === ogRef ? "good" : "bad"}</h2>
904+
<form action={incrementCounterAction}>
905+
<button type="submit" data-submit>
906+
{incrementing ? "Updating via Server Function" : "Update via Server Function"}
907+
</button>
908+
</form>
909+
</div>
910+
);
911+
}
912+
`,
913+
},
914+
});
915+
916+
await page.goto(`http://localhost:${port}/`);
917+
918+
// Verify initial server render
919+
await page.waitForSelector("[data-home]");
920+
expect(await page.locator("[data-home]").textContent()).toBe(
921+
"Home: (0)"
922+
);
923+
await expect(page.locator("[data-home-ref]")).toHaveText("good");
924+
925+
// Submit the form to trigger server function
926+
await page.click("[data-submit]");
927+
928+
// Verify server function updated the UI
929+
await expect(page.locator("[data-home]")).toHaveText("Home: (1)");
930+
await expect(page.locator("[data-home-ref]")).toHaveText("good");
931+
932+
// Submit again to ensure server functions work repeatedly
933+
await page.click("[data-submit]");
934+
await expect(page.locator("[data-home]")).toHaveText("Home: (2)");
935+
await expect(page.locator("[data-home-ref]")).toHaveText("good");
936+
937+
// Ensure this is using RSC
938+
validateRSCHtml(await page.content());
939+
});
872940
});
873941

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

packages/react-router/index-react-server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
export { matchRSCServerRequest as unstable_matchRSCServerRequest } from "./lib/rsc/server.rsc";
33

44
export type {
5-
CreateFromReadableStreamFunction as unstable_CreateFromReadableStreamFunction,
65
DecodeActionFunction as unstable_DecodeActionFunction,
76
DecodeFormStateFunction as unstable_DecodeFormStateFunction,
87
DecodeReplyFunction as unstable_DecodeReplyFunction,

packages/react-router/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,15 @@ export type { Register } from "./lib/types/register";
283283
export { href } from "./lib/href";
284284

285285
// RSC
286-
export type { EncodeReplyFunction as unstable_EncodeReplyFunction } from "./lib/rsc/browser";
286+
export type {
287+
BrowserCreateFromReadableStreamFunction as unstable_BrowserCreateFromReadableStreamFunction,
288+
EncodeReplyFunction as unstable_EncodeReplyFunction,
289+
} from "./lib/rsc/browser";
287290
export {
288291
createCallServer as unstable_createCallServer,
289292
RSCHydratedRouter as unstable_RSCHydratedRouter,
290293
} from "./lib/rsc/browser";
294+
export type { SSRCreateFromReadableStreamFunction as unstable_SSRCreateFromReadableStreamFunction } from "./lib/rsc/server.ssr";
291295
export {
292296
routeRSCServerRequest as unstable_routeRSCServerRequest,
293297
RSCStaticRouter as unstable_RSCStaticRouter,
@@ -300,7 +304,6 @@ import type { matchRSCServerRequest } from "./lib/rsc/server.rsc";
300304
export declare const unstable_matchRSCServerRequest: typeof matchRSCServerRequest;
301305

302306
export type {
303-
CreateFromReadableStreamFunction as unstable_CreateFromReadableStreamFunction,
304307
DecodeActionFunction as unstable_DecodeActionFunction,
305308
DecodeFormStateFunction as unstable_DecodeFormStateFunction,
306309
DecodeReplyFunction as unstable_DecodeReplyFunction,

packages/react-router/lib/rsc/browser.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import type {
1616
RSCPayload,
1717
RSCRouteManifest,
1818
RSCRenderPayload,
19-
CreateFromReadableStreamFunction,
2019
} from "./server.rsc";
2120
import type {
2221
DataStrategyFunction,
@@ -41,7 +40,19 @@ import {
4140
} from "../dom/ssr/routes";
4241
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
4342

44-
export type EncodeReplyFunction = (args: unknown[]) => Promise<BodyInit>;
43+
export type BrowserCreateFromReadableStreamFunction = (
44+
body: ReadableStream<Uint8Array>,
45+
{
46+
temporaryReferences,
47+
}: {
48+
temporaryReferences: unknown;
49+
}
50+
) => Promise<unknown>;
51+
52+
export type EncodeReplyFunction = (
53+
args: unknown[],
54+
options: { temporaryReferences: unknown }
55+
) => Promise<BodyInit>;
4556

4657
declare global {
4758
interface Window {
@@ -53,10 +64,12 @@ declare global {
5364

5465
export function createCallServer({
5566
createFromReadableStream,
67+
createTemporaryReferenceSet,
5668
encodeReply,
5769
fetch: fetchImplementation = fetch,
5870
}: {
59-
createFromReadableStream: CreateFromReadableStreamFunction;
71+
createFromReadableStream: BrowserCreateFromReadableStreamFunction;
72+
createTemporaryReferenceSet: () => unknown;
6073
encodeReply: EncodeReplyFunction;
6174
fetch?: (request: Request) => Promise<Response>;
6275
}) {
@@ -65,9 +78,10 @@ export function createCallServer({
6578
let actionId = (window.__routerActionID =
6679
(window.__routerActionID ??= 0) + 1);
6780

81+
const temporaryReferences = createTemporaryReferenceSet();
6882
const response = await fetchImplementation(
6983
new Request(location.href, {
70-
body: await encodeReply(args),
84+
body: await encodeReply(args, { temporaryReferences }),
7185
method: "POST",
7286
headers: {
7387
Accept: "text/x-component",
@@ -78,9 +92,9 @@ export function createCallServer({
7892
if (!response.body) {
7993
throw new Error("No response body");
8094
}
81-
const payload = (await createFromReadableStream(
82-
response.body
83-
)) as RSCPayload;
95+
const payload = (await createFromReadableStream(response.body, {
96+
temporaryReferences,
97+
})) as RSCPayload;
8498

8599
if (payload.type === "redirect") {
86100
if (payload.reload) {
@@ -159,7 +173,7 @@ function createRouterFromPayload({
159173
payload,
160174
}: {
161175
payload: RSCPayload;
162-
createFromReadableStream: CreateFromReadableStreamFunction;
176+
createFromReadableStream: BrowserCreateFromReadableStreamFunction;
163177
fetchImplementation: (request: Request) => Promise<Response>;
164178
}) {
165179
if (window.__router) return window.__router;
@@ -261,7 +275,7 @@ export function getRSCSingleFetchDataStrategy(
261275
getRouter: () => DataRouter,
262276
ssr: boolean,
263277
basename: string | undefined,
264-
createFromReadableStream: CreateFromReadableStreamFunction,
278+
createFromReadableStream: BrowserCreateFromReadableStreamFunction,
265279
fetchImplementation: (request: Request) => Promise<Response>
266280
): DataStrategyFunction {
267281
// TODO: Clean this up with a shared type
@@ -345,7 +359,7 @@ export function getRSCSingleFetchDataStrategy(
345359
}
346360

347361
function getFetchAndDecodeViaRSC(
348-
createFromReadableStream: CreateFromReadableStreamFunction,
362+
createFromReadableStream: BrowserCreateFromReadableStreamFunction,
349363
fetchImplementation: (request: Request) => Promise<Response>
350364
): FetchAndDecodeFunction {
351365
return async (
@@ -375,7 +389,9 @@ function getFetchAndDecodeViaRSC(
375389
invariant(res.body, "No response body to decode");
376390

377391
try {
378-
const payload = (await createFromReadableStream(res.body)) as RSCPayload;
392+
const payload = (await createFromReadableStream(res.body, {
393+
temporaryReferences: undefined,
394+
})) as RSCPayload;
379395
if (payload.type === "redirect") {
380396
return {
381397
status: res.status,
@@ -434,7 +450,7 @@ export function RSCHydratedRouter({
434450
payload,
435451
routeDiscovery = "eager",
436452
}: {
437-
createFromReadableStream: CreateFromReadableStreamFunction;
453+
createFromReadableStream: BrowserCreateFromReadableStreamFunction;
438454
fetch?: (request: Request) => Promise<Response>;
439455
payload: RSCPayload;
440456
routeDiscovery?: "eager" | "lazy";
@@ -733,7 +749,7 @@ function getManifestUrl(paths: string[]): URL | null {
733749

734750
async function fetchAndApplyManifestPatches(
735751
paths: string[],
736-
createFromReadableStream: CreateFromReadableStreamFunction,
752+
createFromReadableStream: BrowserCreateFromReadableStreamFunction,
737753
fetchImplementation: (request: Request) => Promise<Response>,
738754
signal?: AbortSignal
739755
) {
@@ -755,7 +771,9 @@ async function fetchAndApplyManifestPatches(
755771
throw new Error("Unable to fetch new route matches from the server");
756772
}
757773

758-
let payload = (await createFromReadableStream(response.body)) as RSCPayload;
774+
let payload = (await createFromReadableStream(response.body, {
775+
temporaryReferences: undefined,
776+
})) as RSCPayload;
759777
if (payload.type !== "manifest") {
760778
throw new Error("Failed to patch routes");
761779
}

0 commit comments

Comments
 (0)