Skip to content

Commit 3f55d4f

Browse files
feat: allow async in client props (#315)
1 parent f9cf5e1 commit 3f55d4f

File tree

3 files changed

+80
-12
lines changed

3 files changed

+80
-12
lines changed

packages/reactDom/src/ReactServerDOM.ml

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ module Stream = struct
4343
Lwt.return ());
4444
index
4545

46-
let make ~initial_index =
46+
let make ?(initial_index = 0) ?(pending = 0) () =
4747
let stream, push, close = Push_stream.make () in
48-
(stream, { push; close; pending = 0; index = initial_index })
48+
(stream, { push; close; pending; index = initial_index })
4949
end
5050

5151
(* Resources module maintains insertion order while deduplicating based on src/href *)
@@ -388,7 +388,7 @@ module Model = struct
388388
List.map (fun (name, value) -> (name, client_value_to_json ~context ~to_chunk ~env value)) props
389389

390390
let render ?(env = `Dev) ?(debug = false) ?subscribe element =
391-
let stream, context = Stream.make ~initial_index:0 in
391+
let stream, context = Stream.make () in
392392
let to_root_chunk element id =
393393
let payload = element_to_payload ~debug ~context ~to_chunk ~env element in
394394
to_chunk (Value payload) id
@@ -407,7 +407,7 @@ module Model = struct
407407
let digest = stack |> Hashtbl.hash |> Int.to_string in
408408
Lwt.return (React.Error { message; stack; env = "Server"; digest })
409409
in
410-
let stream, context = Stream.make ~initial_index:0 in
410+
let stream, context = Stream.make () in
411411
let to_root_chunk value id =
412412
let payload = client_value_to_json ~debug ~context ~to_chunk ~env value in
413413
to_chunk (Value payload) id
@@ -501,11 +501,9 @@ let rec client_to_html ~(fiber : Fiber.t) (element : React.element) =
501501
| output -> client_to_html ~fiber output
502502
in
503503
wait_for_suspense_to_resolve ()
504-
| Async_component (_, _component) ->
505-
(* async components can't be interleaved in client components, for now *)
506-
raise
507-
(Invalid_argument
508-
"async components can't be part of a client component. This should never raise, the ppx should catch it")
504+
| Async_component (_, component) ->
505+
let%lwt element = component () in
506+
client_to_html ~fiber element
509507
| Suspense { key = _; children; fallback } ->
510508
(* TODO: Do we need to care if there's Any_promise raising ? *)
511509
let%lwt fallback = client_to_html ~fiber fallback in
@@ -771,8 +769,13 @@ let render_html ?(skipRoot = false) ?(env = `Dev) ?debug:(_ = false) ?bootstrapS
771769
| None -> [])
772770
in
773771
(* Since we don't push the root_data_payload to the stream but return it immediately with the initial HTML,
774-
the stream's initial index starts at 1, with index 0 reserved for the root_data_payload. *)
775-
let stream, context = Stream.make ~initial_index:1 in
772+
the stream's initial index starts at 1, with index 0 reserved for the root_data_payload.
773+
774+
The root is also treated as a pending segment that must complete before the stream can be closed,
775+
as we don't push_async it to the stream, the pending counter starts at 1.
776+
Similar on how react does: https://github.com/facebook/react/blob/7d9f876cbc7e9363092e60436704cf8ae435b969/packages/react-server/src/ReactFizzServer.js#L572-L581
777+
*)
778+
let stream, context = Stream.make ~initial_index:1 ~pending:1 () in
776779
let fiber : Fiber.t =
777780
{
778781
context;
@@ -789,8 +792,10 @@ let render_html ?(skipRoot = false) ?(env = `Dev) ?debug:(_ = false) ?bootstrapS
789792
let%lwt root_html, root_model = render_element_to_html ~fiber element in
790793
(* To return the model value immediately, we don't push it to the stream but return it as a payload script together with the user_scripts *)
791794
let root_data_payload = model_to_chunk (Value root_model) 0 in
795+
(* Decrement the pending counter to signal that the root data payload is complete. *)
796+
context.pending <- context.pending - 1;
792797
(* In case of not having any task pending, we can close the stream *)
793-
(match context.pending = 0 with true -> context.close () | false -> ());
798+
if context.pending = 0 then context.close ();
794799
let bootstrap_script_content =
795800
match bootstrapScriptContent with
796801
| None -> Html.null

packages/reactDom/test/test_RSC_html.ml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,36 @@ let client_with_element_props () =
349349
'>window.srr_stream.push()</script>";
350350
]
351351

352+
let client_component_with_async_component () =
353+
let children =
354+
React.Async_component
355+
( __FUNCTION__,
356+
fun () ->
357+
let%lwt () = sleep ~ms:10 in
358+
Lwt.return (React.string "Async Component") )
359+
in
360+
let app ~children =
361+
React.Upper_case_component
362+
( "app",
363+
fun () ->
364+
React.Client_component
365+
{
366+
import_module = "./client.js";
367+
import_name = "Client";
368+
props = [ ("children", React.Element children) ];
369+
client = children;
370+
} )
371+
in
372+
assert_html (app ~children)
373+
~shell:
374+
"Async Component<script data-payload='0:[\"$\",\"$3\",null,{\"children\":\"$2\"},null,[],{}]\n\
375+
'>window.srr_stream.push()</script>"
376+
[
377+
"<script data-payload='2:\"$L1\"\n'>window.srr_stream.push()</script>";
378+
"<script data-payload='1:\"Async Component\"\n'>window.srr_stream.push()</script>";
379+
"<script data-payload='3:I[\"./client.js\",[],\"Client\"]\n'>window.srr_stream.push()</script>";
380+
]
381+
352382
let suspense_with_error () =
353383
let app () =
354384
React.Suspense.make ~fallback:(React.string "Loading...")
@@ -641,6 +671,7 @@ let tests =
641671
test "suspense_without_promise" suspense_without_promise;
642672
test "with_sleepy_promise" with_sleepy_promise;
643673
test "client_with_promise_props" client_with_promise_props;
674+
test "client_component_with_async_component" client_component_with_async_component;
644675
test "async_component_with_promise" async_component_with_promise;
645676
test "suspense_with_error" suspense_with_error;
646677
test "suspense_with_error_in_async" suspense_with_error_in_async;

packages/reactDom/test/test_RSC_model.ml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,37 @@ let client_component_with_resources_metadata () =
813813
Alcotest.(check bool) "should have head with resources" true has_head_with_resources;
814814
Lwt.return ()
815815

816+
let client_component_with_async_component () =
817+
let async_component =
818+
React.Async_component
819+
( __FUNCTION__,
820+
fun () ->
821+
let%lwt () = sleep ~ms:10 in
822+
Lwt.return (React.string "Async Component") )
823+
in
824+
let app ~children =
825+
React.Upper_case_component
826+
( "app",
827+
fun () ->
828+
React.Client_component
829+
{
830+
import_module = "./client.js";
831+
import_name = "Client";
832+
props = [ ("children", React.Element children) ];
833+
client = children;
834+
} )
835+
in
836+
let output, subscribe = capture_stream () in
837+
let%lwt () = ReactServerDOM.render_model ~subscribe (app ~children:async_component) in
838+
assert_list_of_strings !output
839+
[
840+
"1:I[\"./client.js\",[],\"Client\"]\n";
841+
"3:\"$L2\"\n";
842+
"0:[\"$\",\"$1\",null,{\"children\":\"$3\"},null,[],{}]\n";
843+
"2:\"Async Component\"\n";
844+
];
845+
Lwt.return ()
846+
816847
let page_with_hoisted_resources () =
817848
(* Test that resources like scripts and styles are properly hoisted *)
818849
let app () =
@@ -953,6 +984,7 @@ let tests =
953984
test "client_without_props" client_without_props;
954985
test "client_with_element_props" client_with_element_props;
955986
test "client_with_server_children" client_with_server_children;
987+
test "client_component_with_async_component" client_component_with_async_component;
956988
test "act_with_simple_response" act_with_simple_response;
957989
test "env_development_adds_debug_info" env_development_adds_debug_info;
958990
test "act_with_error" act_with_error;

0 commit comments

Comments
 (0)