Skip to content

Commit 68070f3

Browse files
committed
Implement ACTION_* in server-actions
1 parent 34ff978 commit 68070f3

10 files changed

Lines changed: 1300 additions & 104 deletions

File tree

demo/dream-rsc/DreamRSC.re

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,18 @@ let dispatch_handler = (~lookup, actionId, dispatch) =>
137137
}
138138
};
139139

140-
let handleFormRequest = (~lookup, actionId, formData) => {
140+
let dreamFormDataToJs = formData => {
141141
let formDataJs = Js.FormData.make();
142142
formData
143143
|> List.iter(((name, value)) => {
144144
let (_filename, value) = value |> List.hd;
145145
Js.FormData.append(formDataJs, name, `String(value));
146146
});
147+
formDataJs;
148+
};
149+
150+
let handleFormRequest = (~lookup, actionId, formData) => {
151+
let formDataJs = dreamFormDataToJs(formData);
147152

148153
switch (ReactServerDOM.decodeFormDataReply(formDataJs)) {
149154
| Error(msg) => Lwt.fail_with(msg)
@@ -181,6 +186,24 @@ let handleRequestBody = (~lookup, request, actionId) => {
181186
};
182187
};
183188

189+
let handleNoJsFormRequest = (~lookup, formDataJs) => {
190+
switch (ReactServerDOM.decodeAction(formDataJs)) {
191+
| Some((actionId, userFormData)) =>
192+
switch (lookup(actionId)) {
193+
| None => Lwt.fail_with("Action " ++ actionId ++ " is not registered")
194+
| Some(handler) =>
195+
switch (handler) {
196+
| ReactServerDOM.FormData(handler) => handler([||], userFormData)
197+
| ReactServerDOM.Body(handler) =>
198+
/* No-JS form submissions don't carry serialized args; the form data is the entire payload */
199+
handler([||])
200+
}
201+
}
202+
| None =>
203+
Lwt.fail_with("No ACTION_ID header and no $ACTION_* keys in FormData")
204+
};
205+
};
206+
184207
let handleRequest = (~lookup, request) => {
185208
let actionId = Dream.header(request, "ACTION_ID");
186209
let contentType = Dream.header(request, "Content-Type");
@@ -189,7 +212,15 @@ let handleRequest = (~lookup, request) => {
189212
| Some(contentType)
190213
when String.starts_with(contentType, ~prefix="multipart/form-data") =>
191214
switch%lwt (Dream.multipart(request, ~csrf=false)) {
192-
| `Ok(formData) => handleFormRequest(~lookup, actionId, formData)
215+
| `Ok(formData) =>
216+
switch (actionId) {
217+
| Some(_) =>
218+
/* JS-enabled path: ACTION_ID header present */
219+
handleFormRequest(~lookup, actionId, formData)
220+
| None =>
221+
/* No-JS path: check FormData for $ACTION_* keys */
222+
handleNoJsFormRequest(~lookup, dreamFormDataToJs(formData))
223+
}
193224
| _ =>
194225
Lwt.fail_with(
195226
"Missing form data, this request was not created by server-reason-react",

packages/reactDom/src/ReactServerDOM.ml

Lines changed: 168 additions & 64 deletions
Large diffs are not rendered by default.

packages/reactDom/src/ReactServerDOM.mli

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,15 @@ type server_function =
3939
| FormData of (Yojson.Basic.t array -> Js.FormData.t -> React.model_value Lwt.t)
4040
| Body of (Yojson.Basic.t array -> React.model_value Lwt.t)
4141

42-
val decodeReply : string -> (Yojson.Basic.t array, string) result
43-
val decodeFormDataReply : Js.FormData.t -> (Yojson.Basic.t array * Js.FormData.t, string) result
42+
val decodeReply :
43+
?temporaryReferences:(string -> Yojson.Basic.t option) -> string -> (Yojson.Basic.t array, string) result
44+
45+
val decodeFormDataReply :
46+
?temporaryReferences:(string -> Yojson.Basic.t option) ->
47+
Js.FormData.t ->
48+
(Yojson.Basic.t array * Js.FormData.t, string) result
49+
50+
val decodeAction : Js.FormData.t -> (string * Js.FormData.t) option
4451

4552
module type FunctionReferences = sig
4653
type t

packages/reactDom/test/test_RSC_decoders.ml

Lines changed: 161 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ let unwrap_ok = function Ok v -> v | Error msg -> Alcotest.fail (Printf.sprintf
2121
let unwrap_error = function Error msg -> msg | Ok _ -> Alcotest.fail "expected Error but got Ok"
2222

2323
(* Assert that decodeReply returns Error with a message starting with expected_prefix *)
24-
let assert_decodeReply_errors input expected_prefix () =
25-
match ReactServerDOM.decodeReply input with
24+
let assert_decodeReply_errors ?temporaryReferences input expected_prefix () =
25+
match ReactServerDOM.decodeReply ?temporaryReferences input with
2626
| Ok _ -> Alcotest.fail (Printf.sprintf "expected Error starting with %S" expected_prefix)
2727
| Error msg ->
2828
if not (String.starts_with ~prefix:expected_prefix msg) then
@@ -328,6 +328,33 @@ let decodeFormDataReply_hex_id () =
328328
| [| `List [ `String s ] |] -> assert_string s "from_hex"
329329
| _ -> Alcotest.fail "expected hex ID 'a' to resolve to FormData key '10'"
330330

331+
(* Blob ($B) resolution *)
332+
333+
let decodeFormDataReply_blob () =
334+
let formData = Js.FormData.make () in
335+
Js.FormData.append formData "1" (`String "blob-content-here");
336+
Js.FormData.append formData "0" (`String "[\"$B1\"]");
337+
let args, _ = ReactServerDOM.decodeFormDataReply formData |> unwrap_ok in
338+
match args with
339+
| [| `String data |] -> assert_string data "blob-content-here"
340+
| _ -> Alcotest.fail "expected blob reference to resolve from FormData"
341+
342+
let decodeReply_blob_without_formdata () =
343+
match ReactServerDOM.decodeReply {|["$B1"]|} with
344+
| Error msg ->
345+
if not (String.starts_with ~prefix:"decodeReply: Blob ($B) requires FormData" msg) then
346+
Alcotest.fail (Printf.sprintf "expected FormData error, got %S" msg)
347+
| Ok _ -> Alcotest.fail "expected Error for blob without FormData"
348+
349+
let decodeFormDataReply_blob_missing_entry () =
350+
let formData = Js.FormData.make () in
351+
Js.FormData.append formData "0" (`String "[\"$B1\"]");
352+
match ReactServerDOM.decodeFormDataReply formData with
353+
| Error msg ->
354+
if not (String.starts_with ~prefix:"decodeReply: Blob ($B) entry not found in FormData for key 1" msg) then
355+
Alcotest.fail (Printf.sprintf "expected missing entry error, got %S" msg)
356+
| Ok _ -> Alcotest.fail "expected Error for blob with missing FormData entry"
357+
331358
(* Recursive resolution of nested JSON objects *)
332359

333360
let decodeReply_nested_special_values_in_object () =
@@ -350,6 +377,121 @@ let decodeReply_nested_special_values_in_array () =
350377
assert_float_is_infinity inf_val
351378
| _ -> Alcotest.fail "expected special values in nested arrays to be resolved"
352379

380+
(* Temporary Reference ($T) tests *)
381+
382+
let decodeReply_temporary_reference_resolves () =
383+
let temporaryReferences = function "abc" -> Some (`String "resolved_value") | _ -> None in
384+
let response = ReactServerDOM.decodeReply ~temporaryReferences {|["$Tabc"]|} |> unwrap_ok in
385+
match response with
386+
| [| `String s |] -> assert_string s "resolved_value"
387+
| _ -> Alcotest.fail "expected temporary reference to resolve to stored value"
388+
389+
let decodeReply_temporary_reference_not_found () =
390+
let temporaryReferences = function _ -> None in
391+
match ReactServerDOM.decodeReply ~temporaryReferences {|["$Txyz"]|} with
392+
| Error msg ->
393+
if not (String.starts_with ~prefix:"decodeReply: Temporary Reference $Txyz not found" msg) then
394+
Alcotest.fail (Printf.sprintf "unexpected error message: %S" msg)
395+
| Ok _ -> Alcotest.fail "expected Error for missing temporary reference"
396+
397+
let decodeReply_temporary_reference_no_resolver () =
398+
match ReactServerDOM.decodeReply {|["$Tabc"]|} with
399+
| Error msg ->
400+
if not (String.starts_with ~prefix:"decodeReply: Temporary Reference ($T) requires" msg) then
401+
Alcotest.fail (Printf.sprintf "unexpected error message: %S" msg)
402+
| Ok _ -> Alcotest.fail "expected Error when no temporaryReferences resolver provided"
403+
404+
let decodeReply_temporary_reference_complex_value () =
405+
let temporaryReferences = function
406+
| "obj1" -> Some (`Assoc [ ("key", `String "value"); ("num", `Int 42) ])
407+
| _ -> None
408+
in
409+
let response = ReactServerDOM.decodeReply ~temporaryReferences {|["$Tobj1"]|} |> unwrap_ok in
410+
match response with
411+
| [| `Assoc [ ("key", `String v); ("num", `Int n) ] |] ->
412+
assert_string v "value";
413+
assert_int n 42
414+
| _ -> Alcotest.fail "expected temporary reference to resolve to complex value"
415+
416+
let decodeReply_temporary_reference_in_nested_array () =
417+
let temporaryReferences = function "ref1" -> Some (`String "nested_resolved") | _ -> None in
418+
let response = ReactServerDOM.decodeReply ~temporaryReferences {|[["$Tref1", 42]]|} |> unwrap_ok in
419+
match response with
420+
| [| `List [ `String s; `Int n ] |] ->
421+
assert_string s "nested_resolved";
422+
assert_int n 42
423+
| _ -> Alcotest.fail "expected temporary reference to resolve inside nested array"
424+
425+
let decodeReply_temporary_reference_in_nested_object () =
426+
let temporaryReferences = function "ref1" -> Some (`String "obj_resolved") | _ -> None in
427+
let response = ReactServerDOM.decodeReply ~temporaryReferences {|[{"val": "$Tref1", "other": 1}]|} |> unwrap_ok in
428+
match response with
429+
| [| `Assoc [ ("val", `String s); ("other", `Int n) ] |] ->
430+
assert_string s "obj_resolved";
431+
assert_int n 1
432+
| _ -> Alcotest.fail "expected temporary reference to resolve inside nested object"
433+
434+
(* decodeAction tests *)
435+
436+
let decodeAction_with_action_id_and_fields () =
437+
let formData = Js.FormData.make () in
438+
Js.FormData.append formData "$ACTION_ID_abc123" (`String "");
439+
Js.FormData.append formData "name" (`String "Lola");
440+
Js.FormData.append formData "age" (`String "20");
441+
match ReactServerDOM.decodeAction formData with
442+
| Some (id, user_fd) -> (
443+
assert_string id "abc123";
444+
match (Js.FormData.get user_fd "name", Js.FormData.get user_fd "age") with
445+
| `String name, `String age ->
446+
assert_string name "Lola";
447+
assert_string age "20")
448+
| None -> Alcotest.fail "expected Some but got None"
449+
450+
let decodeAction_no_action_keys () =
451+
let formData = Js.FormData.make () in
452+
Js.FormData.append formData "name" (`String "Lola");
453+
Js.FormData.append formData "age" (`String "20");
454+
match ReactServerDOM.decodeAction formData with None -> () | Some _ -> Alcotest.fail "expected None but got Some"
455+
456+
let decodeAction_action_id_only () =
457+
let formData = Js.FormData.make () in
458+
Js.FormData.append formData "$ACTION_ID_abc123" (`String "");
459+
match ReactServerDOM.decodeAction formData with
460+
| Some (id, user_fd) ->
461+
assert_string id "abc123";
462+
let entries = Js.FormData.entries user_fd in
463+
Alcotest.check Alcotest.int "should have 0 user entries" (List.length entries) 0
464+
| None -> Alcotest.fail "expected Some but got None"
465+
466+
let decodeAction_multiple_action_keys () =
467+
let formData = Js.FormData.make () in
468+
Js.FormData.append formData "$ACTION_ID_first" (`String "");
469+
Js.FormData.append formData "name" (`String "Lola");
470+
Js.FormData.append formData "$ACTION_ID_second" (`String "");
471+
match ReactServerDOM.decodeAction formData with
472+
| Some (id, user_fd) -> (
473+
(* Either action ID is valid since Hashtbl iteration order is unspecified *)
474+
assert_bool (String.equal id "first" || String.equal id "second") true;
475+
match Js.FormData.get user_fd "name" with `String name -> assert_string name "Lola")
476+
| None -> Alcotest.fail "expected Some but got None"
477+
478+
let decodeAction_filters_other_action_keys () =
479+
(* $ACTION_REF_ and other $ACTION_ prefixed keys should be filtered out from user data *)
480+
let formData = Js.FormData.make () in
481+
Js.FormData.append formData "$ACTION_ID_abc123" (`String "");
482+
Js.FormData.append formData "$ACTION_REF_xyz" (`String "some_ref");
483+
Js.FormData.append formData "name" (`String "Lola");
484+
match ReactServerDOM.decodeAction formData with
485+
| Some (id, user_fd) -> (
486+
assert_string id "abc123";
487+
(match Js.FormData.get user_fd "name" with `String name -> assert_string name "Lola");
488+
(* $ACTION_REF_ should not be in user_fd *)
489+
try
490+
let _ = Js.FormData.get user_fd "$ACTION_REF_xyz" in
491+
Alcotest.fail "$ACTION_REF_ key should not be in user FormData"
492+
with Not_found -> ())
493+
| None -> Alcotest.fail "expected Some but got None"
494+
353495
let test title fn = (Printf.sprintf "Decoders / %s" title, [ Alcotest_lwt.test_case_sync "" `Quick fn ])
354496

355497
let tests =
@@ -402,10 +544,18 @@ let tests =
402544
test "decodeReply: $W Set without FormData raises" (assert_decodeReply_errors "[\"$W1\"]" "decodeReply: Set");
403545
(* Unsupported types raise descriptive errors *)
404546
test "decodeReply: $@ Promise raises" (assert_decodeReply_errors "[\"$@1\"]" "decodeReply: Promise");
405-
test "decodeReply: $T Temporary Reference raises"
406-
(assert_decodeReply_errors "[\"$T1\"]" "decodeReply: Temporary Reference");
547+
(* Temporary References ($T) *)
548+
test "decodeReply: $T resolves with temporaryReferences" decodeReply_temporary_reference_resolves;
549+
test "decodeReply: $T not found returns error" decodeReply_temporary_reference_not_found;
550+
test "decodeReply: $T without resolver returns error" decodeReply_temporary_reference_no_resolver;
551+
test "decodeReply: $T resolves complex value" decodeReply_temporary_reference_complex_value;
552+
test "decodeReply: $T resolves in nested array" decodeReply_temporary_reference_in_nested_array;
553+
test "decodeReply: $T resolves in nested object" decodeReply_temporary_reference_in_nested_object;
407554
test "decodeReply: $A TypedArray raises" (assert_decodeReply_errors "[\"$A1\"]" "decodeReply: TypedArray");
408-
test "decodeReply: $B Blob raises" (assert_decodeReply_errors "[\"$B1\"]" "decodeReply: Blob");
555+
(* Blob ($B) resolution *)
556+
test "decodeFormDataReply: $B Blob resolves from FormData" decodeFormDataReply_blob;
557+
test "decodeReply: $B Blob without FormData returns error" decodeReply_blob_without_formdata;
558+
test "decodeFormDataReply: $B Blob with missing entry returns error" decodeFormDataReply_blob_missing_entry;
409559
test "decodeReply: $R ReadableStream raises"
410560
(assert_decodeReply_errors "[\"$R1\"]" "decodeReply: ReadableStream ($R)");
411561
test "decodeReply: $r ReadableStream bytes raises"
@@ -416,4 +566,10 @@ let tests =
416566
List.iter
417567
(fun prefix -> assert_decodeReply_errors (Printf.sprintf "[\"$%s1\"]" prefix) "decodeReply: TypedArray" ())
418568
[ "O"; "o"; "U"; "S"; "s"; "L"; "l"; "G"; "g"; "M"; "m"; "V" ]);
569+
(* decodeAction *)
570+
test "decodeAction: $ACTION_ID with form fields" decodeAction_with_action_id_and_fields;
571+
test "decodeAction: no $ACTION_* keys returns None" decodeAction_no_action_keys;
572+
test "decodeAction: $ACTION_ID with no other fields" decodeAction_action_id_only;
573+
test "decodeAction: multiple $ACTION_ID keys (unspecified which wins)" decodeAction_multiple_action_keys;
574+
test "decodeAction: filters $ACTION_* keys from user data" decodeAction_filters_other_action_keys;
419575
]

packages/reactDom/test/test_RSC_html.ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,8 @@ let server_function_as_action () =
533533
let main = React.Upper_case_component ("app", app) in
534534
assert_html main ~disable_backtrace:true
535535
~shell:
536-
"<form>Server Content</form><script data-payload='0:[\"$\",\"form\",null,{\"children\":\"Server \
536+
"<form action=\"\" method=\"POST\"><input type=\"hidden\" name=\"$ACTION_ID_1234-4321\" value=\"\" />Server \
537+
Content</form><script data-payload='0:[\"$\",\"form\",null,{\"children\":\"Server \
537538
Content\",\"action\":\"$F1\"},null,null,1]\n\
538539
'>window.srr_stream.push()</script>"
539540
[ "<script data-payload='1:{\"id\":\"1234-4321\",\"bound\":null}\n'>window.srr_stream.push()</script>" ]

packages/server-reason-react-ppx/cram/server-function-on-client.t/input.re

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,18 @@ let withFormDataAndArgsDifferentOrder =
7070
let country = country;
7171
Lwt.return(Printf.sprintf("Hello %s, you are from %s", name, country));
7272
};
73+
74+
[@react.server.function]
75+
let withBoolArg = (~flag: bool): Js.Promise.t(string) => {
76+
Js.Promise.resolve(flag ? "yes" : "no");
77+
};
78+
79+
[@react.server.function]
80+
let withListArg = (~names: list(string)): Js.Promise.t(string) => {
81+
Js.Promise.resolve(String.concat(", ", names));
82+
};
83+
84+
[@react.server.function]
85+
let withResultArg = (~res: result(string, string)): Js.Promise.t(string) => {
86+
Js.Promise.resolve("ok");
87+
};

packages/server-reason-react-ppx/cram/server-function-on-client.t/run.t

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,90 @@
268268
},
269269
};
270270
};
271+
272+
include {
273+
{
274+
module J = {
275+
[@ocaml.warning "-unboxable-type-in-prim-decl"]
276+
external unsafe_expr: _ => _ = "#raw_stmt";
277+
};
278+
J.unsafe_expr(
279+
"// extract-server-function 639322061 withBoolArg ",
280+
);
281+
};
282+
283+
let withBoolArg = {
284+
Runtime.id: "639322061",
285+
call: (~flag: bool) => {
286+
let action =
287+
ReactServerDOMEsbuild.createServerReference("639322061");
288+
(
289+
[@ocaml.warning "-ignored-extra-argument"]
290+
Js.Internal.opaqueFullApply(
291+
(Js.Internal.opaque((action: Js.Fn.arity1(_)).I1))(flag),
292+
): _
293+
)
294+
|> Js.Promise.then_(response =>
295+
Js.Promise.resolve(RSC.Primitives.string_of_rsc(response))
296+
);
297+
},
298+
};
299+
};
300+
301+
include {
302+
{
303+
module J = {
304+
[@ocaml.warning "-unboxable-type-in-prim-decl"]
305+
external unsafe_expr: _ => _ = "#raw_stmt";
306+
};
307+
J.unsafe_expr(
308+
"// extract-server-function 543472495 withListArg ",
309+
);
310+
};
311+
312+
let withListArg = {
313+
Runtime.id: "543472495",
314+
call: (~names: list(string)) => {
315+
let action =
316+
ReactServerDOMEsbuild.createServerReference("543472495");
317+
(
318+
[@ocaml.warning "-ignored-extra-argument"]
319+
Js.Internal.opaqueFullApply(
320+
(Js.Internal.opaque((action: Js.Fn.arity1(_)).I1))(names),
321+
): _
322+
)
323+
|> Js.Promise.then_(response =>
324+
Js.Promise.resolve(RSC.Primitives.string_of_rsc(response))
325+
);
326+
},
327+
};
328+
};
329+
330+
include {
331+
{
332+
module J = {
333+
[@ocaml.warning "-unboxable-type-in-prim-decl"]
334+
external unsafe_expr: _ => _ = "#raw_stmt";
335+
};
336+
J.unsafe_expr(
337+
"// extract-server-function 271688509 withResultArg ",
338+
);
339+
};
340+
341+
let withResultArg = {
342+
Runtime.id: "271688509",
343+
call: (~res: result(string, string)) => {
344+
let action =
345+
ReactServerDOMEsbuild.createServerReference("271688509");
346+
(
347+
[@ocaml.warning "-ignored-extra-argument"]
348+
Js.Internal.opaqueFullApply(
349+
(Js.Internal.opaque((action: Js.Fn.arity1(_)).I1))(res),
350+
): _
351+
)
352+
|> Js.Promise.then_(response =>
353+
Js.Promise.resolve(RSC.Primitives.string_of_rsc(response))
354+
);
355+
},
356+
};
357+
};

0 commit comments

Comments
 (0)