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)}
+}