Skip to content

Commit a70e816

Browse files
authored
feat: server functions return RSC payload (#93)
Server functions now return result in RSC payload. This helps with supporting multiple types of results (including binary formats) and also enables the new `reload()` API which enables single-roundtrip mutation and page (or outlet) refresh by combining the server function result with the rendered component in a single RSC payload. Includes tests for multiple server function result types and documentation update to include `reload()`. This PR addresses an issue with server functions without a return value (`Promise<void>`) and enhances the framework to be able to return a rendered component along with the server function return value discussed in #85.
1 parent 0fab13a commit a70e816

19 files changed

+517
-128
lines changed

docs/src/pages/(root).layout.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default function Layout({
2323
<html
2424
lang="en"
2525
className={dark === "1" ? "dark" : dark === "0" ? "light" : null}
26+
suppressHydrationWarning
2627
>
2728
<head>
2829
<meta charSet="utf-8" />
@@ -71,7 +72,7 @@ export default function Layout({
7172
crossOrigin="anonymous"
7273
/>
7374
</head>
74-
<body data-path={pathname}>
75+
<body data-path={pathname} suppressHydrationWarning>
7576
{header}
7677
<main>
7778
{sidebar}

docs/src/pages/en/(pages)/router/server-routing.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,36 @@ export default function App() {
234234
}
235235
```
236236

237+
<Link name="reload">
238+
## Reload
239+
</Link>
240+
241+
In server functions, you can use the `reload` function to reload the current page or an outlet. This is useful when you want to reload the page or an outlet after a mutation to refresh the elements in your app.
242+
243+
```tsx
244+
"use server";
245+
246+
import { reload } from "@lazarv/react-server";
247+
248+
export async function addTodo(todo) {
249+
await addTodo(todo);
250+
reload();
251+
}
252+
```
253+
254+
You can also pass an URL and an outlet name to the `reload` function to render a different route and outlet. You can use this approach to optimize the performance of your app by avoiding unnecessary re-renders of the entire app even when using server functions to mutate data.
255+
256+
```tsx
257+
"use server";
258+
259+
import { reload } from "@lazarv/react-server";
260+
261+
export async function addTodo(todo) {
262+
await addTodo(todo);
263+
reload("/todos", "todo-list");
264+
}
265+
```
266+
237267
<Link name="middlewares">
238268
## Middlewares
239269
</Link>

packages/react-server/client/ClientProvider.jsx

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ const subscribe = (url, listener) => {
2626
urlListeners.add(listener);
2727
return () => urlListeners.delete(listener);
2828
};
29-
const emit = (url, to = url, callback = () => {}) => {
29+
const emit = (url, to = url, options = {}, callback = () => {}) => {
3030
if (!listeners.has(url)) return callback();
3131
const urlListeners = listeners.get(url);
32-
for (const listener of urlListeners) listener(to, callback);
32+
for (const listener of urlListeners) listener(to, options, callback);
3333
};
3434
const prefetchOutlet = (to, { outlet = PAGE_ROOT, ttl = Infinity }) => {
3535
if (prefetching.get(outlet) !== to) {
@@ -76,7 +76,7 @@ const refresh = async (outlet = PAGE_ROOT) => {
7676
cache.delete(url);
7777
cache.delete(outlet);
7878
}
79-
emit(outlet, url, (err) => {
79+
emit(outlet, url, {}, (err) => {
8080
if (err) reject(err);
8181
else {
8282
activeChunk.set(outlet, cache.get(outlet));
@@ -129,7 +129,7 @@ const navigateOutlet = (to, { outlet = PAGE_ROOT, push, rollback = 0 }) => {
129129
cache.delete(to);
130130
cache.delete(outlet);
131131
}
132-
emit(outlet, to, (err) => {
132+
emit(outlet, to, {}, (err) => {
133133
if (err) reject(err);
134134
else {
135135
activeChunk.set(outlet, cache.get(outlet));
@@ -184,7 +184,7 @@ window.addEventListener("popstate", () => {
184184
flightCache.delete(timeoutKey);
185185
}
186186
outlets.set(PAGE_ROOT, location.href);
187-
emit(PAGE_ROOT, location.href, (err) => {
187+
emit(PAGE_ROOT, location.href, {}, (err) => {
188188
if (!err) {
189189
activeChunk.set(PAGE_ROOT, cache.get(PAGE_ROOT));
190190
}
@@ -209,7 +209,7 @@ window.addEventListener("popstate", () => {
209209
cache.delete(location.href);
210210
}
211211
outlets.set(outlet, location.href);
212-
emit(outlet, location.href, (err) => {
212+
emit(outlet, location.href, {}, (err) => {
213213
if (!err) {
214214
activeChunk.set(outlet, cache.get(outlet));
215215
}
@@ -223,52 +223,57 @@ export const streamOptions = (outlet, remote) => ({
223223
try {
224224
const formData = await encodeReply(args);
225225
const url = outlet || PAGE_ROOT;
226-
if (
227-
formData instanceof FormData &&
228-
!Array.from(formData.keys()).find((key) =>
229-
key.includes("$ACTION_KEY")
230-
)
231-
) {
232-
let target = outlet;
233-
cache.delete(url);
234-
cache.delete(target);
235-
getFlightResponse(outlets.get(target) || url, {
236-
method: "POST",
237-
body: formData,
238-
outlet: target,
239-
remote,
240-
standalone: target !== PAGE_ROOT,
241-
headers: Array.from(formData.keys()).find((key) =>
242-
key.includes("ACTION_ID")
243-
)
226+
227+
let target = outlet;
228+
cache.delete(url);
229+
cache.delete(target);
230+
getFlightResponse(outlets.get(target) || url, {
231+
method: "POST",
232+
body: formData,
233+
outlet: target,
234+
remote,
235+
standalone: target !== PAGE_ROOT,
236+
callServer: id || true,
237+
onFetch: (res) => {
238+
const callServer =
239+
typeof res.headers.get("React-Server-Data") === "string"
240+
? id || true
241+
: false;
242+
emit(target, url, { callServer }, async (err, result) => {
243+
if (err) reject(err);
244+
else {
245+
if (!callServer) {
246+
return resolve();
247+
}
248+
const rsc = await result;
249+
try {
250+
const value = await rsc.at(-1);
251+
resolve(value);
252+
253+
const url = res.headers.get("React-Server-Render");
254+
const outlet = res.headers.get("React-Server-Outlet");
255+
256+
if (url && outlet) {
257+
cache.set(outlet, rsc.slice(0, -1));
258+
emit(outlet, url, {});
259+
}
260+
} catch (e) {
261+
reject(e);
262+
}
263+
}
264+
});
265+
},
266+
onError: (err) => {
267+
reject(err);
268+
},
269+
headers:
270+
formData instanceof FormData &&
271+
Array.from(formData.keys()).find((key) => key.includes("ACTION_ID"))
244272
? {}
245273
: {
246274
"React-Server-Action": encodeURIComponent(id),
247275
},
248-
});
249-
emit(target, url, (err, result) => {
250-
if (err) reject(err);
251-
else resolve(result);
252-
});
253-
} else {
254-
const response = await fetch(
255-
url === PAGE_ROOT ? location.href : url,
256-
{
257-
method: "POST",
258-
body: formData,
259-
headers: {
260-
accept: "application/json",
261-
"React-Server-Action": encodeURIComponent(id),
262-
"React-Server-Outlet": encodeURIComponent(outlet || PAGE_ROOT),
263-
},
264-
}
265-
);
266-
if (!response.ok) {
267-
reject(new Error(response.statusText));
268-
} else {
269-
resolve(await response.json());
270-
}
271-
}
276+
});
272277
} catch (e) {
273278
reject(e);
274279
}
@@ -309,7 +314,16 @@ function getFlightResponse(url, options = {}) {
309314
),
310315
...options.headers,
311316
},
312-
}),
317+
}).then(
318+
(res) => {
319+
options.onFetch?.(res);
320+
return res;
321+
},
322+
(err) => {
323+
options.onError?.(err);
324+
throw err;
325+
}
326+
),
313327
streamOptions(options.outlet || url, options.remote)
314328
)
315329
);

packages/react-server/client/ReactServerComponent.jsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,23 @@ function FlightComponent({
3636
useEffect(() => {
3737
let mounted = true;
3838
const unregisterOutlet = registerOutlet(outlet, url);
39-
const unsubscribe = subscribe(outlet || url, (to, callback) => {
39+
const unsubscribe = subscribe(outlet || url, (to, options, callback) => {
4040
const nextComponent = getFlightResponse(to, {
4141
outlet,
4242
standalone,
4343
remote,
4444
request,
4545
});
4646
if (!mounted) return;
47-
startTransition(() => {
48-
setError(null);
49-
setComponent(nextComponent);
47+
if (options.callServer) {
5048
callback(null, nextComponent);
51-
});
49+
} else {
50+
startTransition(() => {
51+
setError(null);
52+
setComponent(nextComponent);
53+
callback(null, nextComponent);
54+
});
55+
}
5256
});
5357
return () => {
5458
mounted = false;

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,10 @@ export function useFormData(): FormData;
132132
* Rewrites the current request URL to the specified pathname.
133133
*
134134
* @param pathname - The new pathname to use
135+
*
136+
* @returns The new URL object
135137
*/
136-
export function rewrite(pathname: string): void;
138+
export function rewrite(pathname: string): URL;
137139

138140
/**
139141
* Revalidates the current request cache.
@@ -153,6 +155,15 @@ export function invalidate<T extends (...args: any[]) => any>(
153155
fn: T
154156
): Promise<void>;
155157

158+
/**
159+
* Instructs the framework to reload the page or the specified outlet after executing the current server function, using the component rendered at the specified URL.
160+
* Only usable inside server functions!
161+
*
162+
* @param url - Render the component at the specified URL
163+
* @param outlet - The outlet to reload after rendering the component (defaults to page root)
164+
*/
165+
export function reload(url?: URL | string, outlet?: string): void;
166+
156167
/**
157168
* Sets the status code and status text of the response.
158169
*
@@ -196,6 +207,13 @@ export function headers(headers?: Record<string, string>): void;
196207
*/
197208
export function useOutlet(): string;
198209

210+
/**
211+
* Get or set the active outlet.
212+
*
213+
* @param target - The outlet name
214+
*/
215+
export function outlet(target: string): string;
216+
199217
export type Cookies = RequestContextExtensions["cookie"];
200218

201219
/**

packages/react-server/server/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { headers } from "./http-headers.mjs";
44
export { status } from "./http-status.mjs";
55
export { redirect } from "./redirects.mjs";
66
export {
7+
outlet,
78
rewrite,
89
useFormData,
910
useHttpContext,
@@ -16,3 +17,4 @@ export {
1617
} from "./request.mjs";
1718
export { revalidate } from "./revalidate.mjs";
1819
export { invalidate, useCache } from "../memory-cache/index.mjs";
20+
export { reload } from "./reload.mjs";
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { context$ } from "./context.mjs";
2+
import { outlet, rewrite, useUrl } from "./request.mjs";
3+
import { RELOAD } from "./symbols.mjs";
4+
5+
export function reload(url, target) {
6+
const currentUrl = url ? rewrite(url) : useUrl();
7+
const currentOutlet = outlet(target);
8+
context$(RELOAD, { url: currentUrl, outlet: currentOutlet });
9+
}

0 commit comments

Comments
 (0)