diff --git a/changes/jsx-async-directive.fix.md b/changes/jsx-async-directive.fix.md
new file mode 100644
index 0000000000..5e1f2dccc8
--- /dev/null
+++ b/changes/jsx-async-directive.fix.md
@@ -0,0 +1 @@
+- JSX v4: keep `@directive` on an async component's own function instead of its generated sync wrapper, so `@react.component let make = @directive("'use cache'") async (...) => ...` compiles to an async function starting with `'use cache'`, which Next.js accepts.
diff --git a/compiler/syntax/src/jsx_v4.ml b/compiler/syntax/src/jsx_v4.ml
index f386ace3b0..bd4afe2009 100644
--- a/compiler/syntax/src/jsx_v4.ml
+++ b/compiler/syntax/src/jsx_v4.ml
@@ -79,6 +79,15 @@ let filename_from_loc (pstr_loc : Location.t) =
file_name
(* Build a string representation of a module name with segments separated by $ *)
+(* An async component's code runs in the inner [make]; the generated wrapper
+ only returns its promise. A directive ('use cache', 'use server') has to sit
+ on the async function, so it stays on [make]. A sync wrapper has [make]
+ inlined into it, so there the directive stays on the wrapper. Returns the
+ wrapper's attributes and [make]'s. *)
+let split_directive_attrs ~async attrs =
+ if async then List.partition (fun ({txt}, _) -> txt <> "directive") attrs
+ else (attrs, [])
+
let make_module_name file_name nested_modules fn_name =
let full_module_name =
match (file_name, nested_modules, fn_name) with
@@ -602,6 +611,9 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
modified_binding ~binding_loc ~binding_pat_loc ~fn_name binding
in
let is_async = Ast_async.dig_async_payload_from_function binding.pvb_expr in
+ let wrapper_attrs, make_attrs =
+ split_directive_attrs ~async:is_async binding.pvb_expr.pexp_attributes
+ in
let named_arg_list, newtypes, _typeConstraints =
recursively_transform_named_args_for_make
(modified_binding_old binding)
@@ -652,7 +664,7 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
]
else [props_param]
in
- Exp.fun_ ~attrs:binding.pvb_expr.pexp_attributes params inner_expression
+ Exp.fun_ ~attrs:wrapper_attrs params inner_expression
in
let full_expression =
match full_module_name with
@@ -801,7 +813,7 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
| _ -> [Typ.any ()]))))
in
(* Add the collected newtypes (type a b c) to make's definition *)
- Exp.fun_ ~async:is_async ~newtypes:(List.rev newtypes)
+ Exp.fun_ ~attrs:make_attrs ~async:is_async ~newtypes:(List.rev newtypes)
(props_param :: nolabel_params)
expression
in
@@ -825,6 +837,9 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
let is_async =
Ast_async.dig_async_payload_from_function modified_binding.pvb_expr
in
+ let wrapper_attrs, make_attrs =
+ split_directive_attrs ~async:is_async binding.pvb_expr.pexp_attributes
+ in
let make_new_binding ~loc ~full_module_name binding =
let props_pattern =
@@ -884,7 +899,7 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
in
let applied_expression = constrain_jsx_return applied_expression in
let wrapper_expr =
- Exp.fun_ ~attrs:binding.pvb_expr.pexp_attributes
+ Exp.fun_ ~attrs:wrapper_attrs
[Exp.fun_param Nolabel props_pattern]
applied_expression
in
@@ -908,8 +923,8 @@ let map_binding ~config ~empty_loc ~pstr_loc ~file_name binding =
let binding_expr =
{
binding.pvb_expr with
- (* moved to wrapper_expr *)
- pexp_attributes = [];
+ (* moved to wrapper_expr, but for a directive on an async component *)
+ pexp_attributes = make_attrs;
}
in
( None,
diff --git a/tests/syntax_tests/data/ppx/react/asyncDirective.res b/tests/syntax_tests/data/ppx/react/asyncDirective.res
new file mode 100644
index 0000000000..abb95adb9b
--- /dev/null
+++ b/tests/syntax_tests/data/ppx/react/asyncDirective.res
@@ -0,0 +1,19 @@
+module Async = {
+ @react.component
+ let make =
+ @directive("'use cache';") async (~id: string) =>
{React.string(id)}
+}
+
+module AsyncWithProps = {
+ type props = {id: string}
+
+ @react.componentWithProps
+ let make =
+ @directive("'use cache';") async (props: props) => {React.string(props.id)}
+}
+
+module Sync = {
+ @react.component
+ let make =
+ @directive("'use no memo';") (~id: string) => {React.string(id)}
+}
diff --git a/tests/syntax_tests/data/ppx/react/expected/asyncDirective.res.txt b/tests/syntax_tests/data/ppx/react/expected/asyncDirective.res.txt
new file mode 100644
index 0000000000..80a96f7659
--- /dev/null
+++ b/tests/syntax_tests/data/ppx/react/expected/asyncDirective.res.txt
@@ -0,0 +1,44 @@
+module Async = {
+ @res.jsxComponentProps
+ type props<'id> = {
+ id: 'id,
+ }
+
+ let make =
+ @directive("'use cache';") async ({id, _}: props): React.element =>
+ ReactDOM.jsx("div", {children: ?ReactDOM.someElement({React.string(id)})})
+
+ let make = React.component({
+ let \"AsyncDirective$Async" = (props: props<_>) => Jsx.promise(make(props))
+ \"AsyncDirective$Async"
+ })
+}
+
+module AsyncWithProps = {
+ type props = {id: string}
+
+ let make =
+ @directive("'use cache';") async (props: props): React.element =>
+ ReactDOM.jsx("div", {children: ?ReactDOM.someElement({React.string(props.id)})})
+
+ let make = React.component({
+ let \"AsyncDirective$AsyncWithProps" = (props: props): React.element => Jsx.promise(make(props))
+ \"AsyncDirective$AsyncWithProps"
+ })
+}
+
+module Sync = {
+ @res.jsxComponentProps
+ type props<'id> = {
+ id: 'id,
+ }
+
+ let make = ({id, _}: props): React.element =>
+ ReactDOM.jsx("div", {children: ?ReactDOM.someElement({React.string(id)})})
+
+ let make = React.component({
+ let \"AsyncDirective$Sync" = @directive("'use no memo';") (props: props<_>) => make(props)
+
+ \"AsyncDirective$Sync"
+ })
+}
diff --git a/tests/tests/src/async_jsx_directive.mjs b/tests/tests/src/async_jsx_directive.mjs
new file mode 100644
index 0000000000..d3874a6433
--- /dev/null
+++ b/tests/tests/src/async_jsx_directive.mjs
@@ -0,0 +1,56 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as JsxRuntime from "react/jsx-runtime";
+
+async function make(param) {
+ 'use cache';
+ return JsxRuntime.jsx("div", {
+ children: param.id
+ });
+}
+
+let Async_jsx_directive$Cached = make;
+
+let Cached = {
+ make: Async_jsx_directive$Cached
+};
+
+async function make$1(props) {
+ 'use cache';
+ return JsxRuntime.jsx("div", {
+ children: props.id
+ });
+}
+
+let Async_jsx_directive$CachedWithProps = make$1;
+
+let CachedWithProps = {
+ make: Async_jsx_directive$CachedWithProps
+};
+
+function Async_jsx_directive$NoMemo(props) {
+ 'use no memo';
+ return JsxRuntime.jsx("div", {
+ children: props.id
+ });
+}
+
+let NoMemo = {
+ make: Async_jsx_directive$NoMemo
+};
+
+let Cached$make = Async_jsx_directive$Cached;
+
+let CachedWithProps$make = Async_jsx_directive$CachedWithProps;
+
+let NoMemo$make = Async_jsx_directive$NoMemo;
+
+export {
+ Cached,
+ CachedWithProps,
+ NoMemo,
+ Cached$make,
+ CachedWithProps$make,
+ NoMemo$make,
+}
+/* react/jsx-runtime Not a pure module */
diff --git a/tests/tests/src/async_jsx_directive.res b/tests/tests/src/async_jsx_directive.res
new file mode 100644
index 0000000000..1419e51508
--- /dev/null
+++ b/tests/tests/src/async_jsx_directive.res
@@ -0,0 +1,28 @@
+@@config({
+ flags: ["-bs-jsx", "4"],
+})
+
+// A directive on an async component belongs on the async function itself,
+// not on the generated sync wrapper: 'use cache' on a sync function is
+// rejected by Next.js.
+
+module Cached = {
+ @react.component
+ let make =
+ @directive("'use cache'") async (~id: string) => {React.string(id)}
+}
+
+module CachedWithProps = {
+ type props = {id: string}
+
+ @react.componentWithProps
+ let make =
+ @directive("'use cache'") async (props: props) => {React.string(props.id)}
+}
+
+// A sync component's wrapper inlines its body, so it keeps the directive.
+module NoMemo = {
+ @react.component
+ let make =
+ @directive("'use no memo'") (~id: string) => {React.string(id)}
+}