Skip to content

Commit 7150677

Browse files
Inject client route component props during RSC render (#14007)
1 parent a0b3abb commit 7150677

File tree

7 files changed

+496
-85
lines changed

7 files changed

+496
-85
lines changed

.changeset/neat-jeans-sneeze.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
[REMOVE] Inject client route component props during RSC render

integration/rsc/rsc-test.ts

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2208,5 +2208,324 @@ implementations.forEach((implementation) => {
22082208
validateRSCHtml(await page.content());
22092209
});
22102210
});
2211+
2212+
test.describe("Route Client Component Props", () => {
2213+
test("Passes props to client route component", async ({ page }) => {
2214+
let port = await getPort();
2215+
stop = await setupRscTest({
2216+
implementation,
2217+
port,
2218+
files: {
2219+
"src/routes/home.tsx": js`
2220+
export { default, clientLoader, clientAction } from "./home.client";
2221+
`,
2222+
"src/routes/home.client.tsx": js`
2223+
"use client";
2224+
2225+
import { Form } from "react-router";
2226+
2227+
export async function clientLoader() {
2228+
return { message: "Hello from client loader!" };
2229+
}
2230+
2231+
export async function clientAction({ request }) {
2232+
const formData = await request.formData();
2233+
const name = formData.get("name") as string;
2234+
return { actionResult: "Hello " + name + " from client action!" };
2235+
}
2236+
2237+
export default function HomeRoute({ loaderData, actionData, matches, params }) {
2238+
return (
2239+
<div>
2240+
<h2 data-home>Home Route</h2>
2241+
{loaderData && (
2242+
<p data-loader-data>{loaderData.message}</p>
2243+
)}
2244+
{actionData && (
2245+
<p data-action-data>{actionData.actionResult}</p>
2246+
)}
2247+
{matches && (
2248+
<div data-matches>
2249+
<p data-matches-ids>matches ids: {matches.map(match => match.id).join(", ")}</p>
2250+
</div>
2251+
)}
2252+
{params && (
2253+
<div data-params>
2254+
<p data-params-type>typeof params: {typeof params}</p>
2255+
<p data-params-count>params count: {Object.keys(params).length}</p>
2256+
</div>
2257+
)}
2258+
<Form method="post">
2259+
<input name="name" data-name-input />
2260+
<button type="submit" data-submit-button>
2261+
Submit Action
2262+
</button>
2263+
</Form>
2264+
</div>
2265+
);
2266+
}
2267+
`,
2268+
},
2269+
});
2270+
2271+
await page.goto(`http://localhost:${port}/`);
2272+
2273+
// Verify loader data is passed
2274+
await page.waitForSelector("[data-loader-data]");
2275+
expect(await page.locator("[data-loader-data]").textContent()).toBe(
2276+
"Hello from client loader!"
2277+
);
2278+
2279+
// Verify params are passed (empty for home route)
2280+
await page.waitForSelector("[data-params]");
2281+
await page.waitForSelector("[data-params-type]");
2282+
await page.waitForSelector("[data-params-count]");
2283+
expect(await page.locator("[data-params-type]").textContent()).toBe(
2284+
"typeof params: object"
2285+
);
2286+
expect(await page.locator("[data-params-count]").textContent()).toBe(
2287+
"params count: 0"
2288+
);
2289+
2290+
// Verify matches are passed
2291+
await page.waitForSelector("[data-matches]");
2292+
await page.waitForSelector("[data-matches-ids]");
2293+
expect(await page.locator("[data-matches-ids]").textContent()).toBe(
2294+
"matches ids: root, home"
2295+
);
2296+
2297+
// Submit the form to trigger the client action
2298+
await page.fill("[data-name-input]", "World");
2299+
await page.click("[data-submit-button]");
2300+
2301+
// Verify the action data is displayed
2302+
await page.waitForSelector("[data-action-data]");
2303+
expect(await page.locator("[data-action-data]").textContent()).toBe(
2304+
"Hello World from client action!"
2305+
);
2306+
2307+
// Ensure this is using RSC
2308+
validateRSCHtml(await page.content());
2309+
});
2310+
2311+
test("Passes props to client ErrorBoundary when error is thrown in client loader", async ({
2312+
page,
2313+
}) => {
2314+
let port = await getPort();
2315+
stop = await setupRscTest({
2316+
implementation,
2317+
port,
2318+
files: {
2319+
"src/routes/home.tsx": js`
2320+
export { default, clientLoader, ErrorBoundary } from "./home.client";
2321+
`,
2322+
"src/routes/home.client.tsx": js`
2323+
"use client";
2324+
2325+
export async function clientLoader() {
2326+
throw new Error("Intentional error from client loader");
2327+
}
2328+
2329+
export function ErrorBoundary({ error, params }) {
2330+
return (
2331+
<div>
2332+
<h2 data-error-title>Error Caught!</h2>
2333+
<p data-error-message>{error.message}</p>
2334+
{params && (
2335+
<div data-error-params>
2336+
<p data-error-params-type>typeof params: {typeof params}</p>
2337+
<p data-error-params-count>params count: {Object.keys(params).length}</p>
2338+
</div>
2339+
)}
2340+
</div>
2341+
);
2342+
}
2343+
2344+
export default function HomeRoute() {
2345+
return (
2346+
<div>
2347+
<h2>Home Route</h2>
2348+
</div>
2349+
);
2350+
}
2351+
`,
2352+
},
2353+
});
2354+
2355+
await page.goto(`http://localhost:${port}/`);
2356+
2357+
// Verify error boundary is shown
2358+
await page.waitForSelector("[data-error-title]");
2359+
await page.waitForSelector("[data-error-message]");
2360+
expect(await page.locator("[data-error-title]").textContent()).toBe(
2361+
"Error Caught!"
2362+
);
2363+
expect(await page.locator("[data-error-message]").textContent()).toBe(
2364+
"Intentional error from client loader"
2365+
);
2366+
2367+
// Verify params are passed to error boundary
2368+
await page.waitForSelector("[data-error-params]");
2369+
await page.waitForSelector("[data-error-params-type]");
2370+
await page.waitForSelector("[data-error-params-count]");
2371+
expect(
2372+
await page.locator("[data-error-params-type]").textContent()
2373+
).toBe("typeof params: object");
2374+
expect(
2375+
await page.locator("[data-error-params-count]").textContent()
2376+
).toBe("params count: 0");
2377+
2378+
// Ensure this is using RSC
2379+
validateRSCHtml(await page.content());
2380+
});
2381+
2382+
test("Passes props to client ErrorBoundary when error is thrown in server loader", async ({
2383+
page,
2384+
}) => {
2385+
let port = await getPort();
2386+
stop = await setupRscTest({
2387+
implementation,
2388+
port,
2389+
dev: true,
2390+
files: {
2391+
"src/routes/home.tsx": js`
2392+
export function loader() {
2393+
throw new Error("Intentional error from server loader");
2394+
}
2395+
2396+
export default function HomeRoute() {
2397+
return <h2>This should not be rendered</h2>;
2398+
}
2399+
2400+
export { ErrorBoundary } from "./home.client";
2401+
`,
2402+
"src/routes/home.client.tsx": js`
2403+
"use client";
2404+
2405+
export function ErrorBoundary({ error, params }) {
2406+
return (
2407+
<div>
2408+
<h2 data-error-title>Error Caught!</h2>
2409+
<p data-error-message>{error.message}</p>
2410+
{params && (
2411+
<div data-error-params>
2412+
<p data-error-params-type>typeof params: {typeof params}</p>
2413+
<p data-error-params-count>params count: {Object.keys(params).length}</p>
2414+
</div>
2415+
)}
2416+
</div>
2417+
);
2418+
}
2419+
`,
2420+
},
2421+
});
2422+
2423+
await page.goto(`http://localhost:${port}/`);
2424+
2425+
// Verify error boundary is shown
2426+
await page.waitForSelector("[data-error-title]");
2427+
await page.waitForSelector("[data-error-message]");
2428+
expect(await page.locator("[data-error-title]").textContent()).toBe(
2429+
"Error Caught!"
2430+
);
2431+
expect(await page.locator("[data-error-message]").textContent()).toBe(
2432+
"Intentional error from server loader"
2433+
);
2434+
2435+
// Verify params are passed to error boundary
2436+
await page.waitForSelector("[data-error-params]");
2437+
await page.waitForSelector("[data-error-params-type]");
2438+
await page.waitForSelector("[data-error-params-count]");
2439+
expect(
2440+
await page.locator("[data-error-params-type]").textContent()
2441+
).toBe("typeof params: object");
2442+
expect(
2443+
await page.locator("[data-error-params-count]").textContent()
2444+
).toBe("params count: 0");
2445+
2446+
// Ensure this is using RSC
2447+
validateRSCHtml(await page.content());
2448+
});
2449+
2450+
test("Passes props to client HydrateFallback", async ({ page }) => {
2451+
let port = await getPort();
2452+
stop = await setupRscTest({
2453+
implementation,
2454+
port,
2455+
files: {
2456+
"src/routes/home.tsx": js`
2457+
export { default, clientLoader, HydrateFallback } from "./home.client";
2458+
`,
2459+
"src/routes/home.client.tsx": js`
2460+
"use client";
2461+
2462+
export async function clientLoader() {
2463+
const pollingPromise = (async () => {
2464+
while (globalThis.unblockClientLoader !== true) {
2465+
await new Promise((resolve) => setTimeout(resolve, 0));
2466+
}
2467+
})();
2468+
const timeoutPromise = new Promise((_, reject) => {
2469+
setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000);
2470+
});
2471+
await Promise.race([pollingPromise, timeoutPromise]);
2472+
return { message: "Hello from client loader!" };
2473+
}
2474+
2475+
export function HydrateFallback({ params }) {
2476+
return (
2477+
<div>
2478+
<h2 data-hydrate-fallback>Hydrate Fallback</h2>
2479+
{params && (
2480+
<div data-hydrate-params>
2481+
<p data-hydrate-params-type>typeof params: {typeof params}</p>
2482+
<p data-hydrate-params-count>params count: {Object.keys(params).length}</p>
2483+
</div>
2484+
)}
2485+
</div>
2486+
);
2487+
}
2488+
2489+
export default function HomeRoute() {
2490+
return (
2491+
<div>
2492+
<h2 data-home>Home Route</h2>
2493+
</div>
2494+
);
2495+
}
2496+
`,
2497+
},
2498+
});
2499+
2500+
await page.goto(`http://localhost:${port}/`);
2501+
2502+
// Verify the hydrate fallback is shown initially
2503+
await page.waitForSelector("[data-hydrate-fallback]");
2504+
expect(
2505+
await page.locator("[data-hydrate-fallback]").textContent()
2506+
).toBe("Hydrate Fallback");
2507+
2508+
// Verify params are passed to hydrate fallback
2509+
await page.waitForSelector("[data-hydrate-params]");
2510+
await page.waitForSelector("[data-hydrate-params-type]");
2511+
await page.waitForSelector("[data-hydrate-params-count]");
2512+
expect(
2513+
await page.locator("[data-hydrate-params-type]").textContent()
2514+
).toBe("typeof params: object");
2515+
expect(
2516+
await page.locator("[data-hydrate-params-count]").textContent()
2517+
).toBe("params count: 0");
2518+
2519+
// Unblock the client loader to allow it to complete
2520+
await page.evaluate(() => {
2521+
(globalThis as any).unblockClientLoader = true;
2522+
});
2523+
2524+
await page.waitForSelector("[data-home]");
2525+
2526+
// Ensure this is using RSC
2527+
validateRSCHtml(await page.content());
2528+
});
2529+
});
22112530
});
22122531
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export {
99
Router,
1010
RouterProvider,
1111
Routes,
12+
WithComponentProps as UNSAFE_WithComponentProps,
13+
WithErrorBoundaryProps as UNSAFE_WithErrorBoundaryProps,
14+
WithHydrateFallbackProps as UNSAFE_WithHydrateFallbackProps,
1215
} from "./lib/components";
1316
export {
1417
BrowserRouter,

packages/react-router/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,11 @@ export {
358358
export {
359359
hydrationRouteProperties as UNSAFE_hydrationRouteProperties,
360360
mapRouteProperties as UNSAFE_mapRouteProperties,
361+
WithComponentProps as UNSAFE_WithComponentProps,
361362
withComponentProps as UNSAFE_withComponentProps,
363+
WithHydrateFallbackProps as UNSAFE_WithHydrateFallbackProps,
362364
withHydrateFallbackProps as UNSAFE_withHydrateFallbackProps,
365+
WithErrorBoundaryProps as UNSAFE_WithErrorBoundaryProps,
363366
withErrorBoundaryProps as UNSAFE_withErrorBoundaryProps,
364367
} from "./lib/components";
365368

0 commit comments

Comments
 (0)