diff --git a/changes/jsx-component-without-make.polish.md b/changes/jsx-component-without-make.polish.md
new file mode 100644
index 0000000000..4354057b7c
--- /dev/null
+++ b/changes/jsx-component-without-make.polish.md
@@ -0,0 +1 @@
+- Explain that a JSX tag needs a component module that defines `make`, when the module it names doesn't. https://github.com/rescript-lang/rescript/pull/8885
diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml
index a8766cee57..93fd3c507b 100644
--- a/compiler/ml/typecore.ml
+++ b/compiler/ml/typecore.ml
@@ -98,6 +98,7 @@ type error =
| Type_params_not_supported of Longident.t
| Field_access_on_dict_type
| Jsx_not_enabled
+ | Jsx_component_without_make of Longident.t
| Jsx_unboxed_props of type_expr
| Tagged_template_non_tag of type_expr
| Record_rest of Typecore_record_rest.error
@@ -2530,12 +2531,20 @@ and type_expect_ ?deprecated_context ~context ?(recarg = Rejected) env sexp
{exp with exp_attributes = sexp.pexp_attributes @ exp.exp_attributes}
| Pexp_ident lid ->
let path, desc =
- Typetexp.find_value
- ?deprecated_context:
- (match deprecated_context with
- | None -> Some Reference
- | v -> v)
- env lid.loc lid.txt
+ try
+ Typetexp.find_value
+ ?deprecated_context:
+ (match deprecated_context with
+ | None -> Some Reference
+ | v -> v)
+ env lid.loc lid.txt
+ with
+ | Typetexp.Error (_, _, Unbound_value (Ldot (module_lid, "make")))
+ when context = Some JsxComponent
+ ->
+ (* [] refers to [Button.make]. The module exists (otherwise
+ the lookup reports it as unbound), but it defines no component. *)
+ raise (Error (lid.loc, env, Jsx_component_without_make module_lid))
in
let is_recarg =
match (repr desc.val_type).desc with
@@ -5570,6 +5579,19 @@ let report_error env loc ppf error =
fprintf ppf
"Cannot compile JSX expression because JSX support is not enabled. Add \
\"jsx\" settings to rescript.json to enable JSX support."
+ | Jsx_component_without_make module_lid ->
+ fprintf ppf
+ "@[The module @{%a@} can't be used as a JSX component, because \
+ it doesn't define @{make@}.@,\
+ @,\
+ A component module defines its component as @{make@}, for \
+ example:@,\
+ @,\
+ \ @{@react.component@}@,\
+ \ let make = (~label) => ...@]"
+ longident module_lid;
+ ignore
+ (Typetexp.spellcheck_value ppf env (Longident.Ldot (module_lid, "make")))
| Jsx_unboxed_props typ ->
fprintf ppf
"@[The props of this JSX element have type %a, which is an \
diff --git a/compiler/ml/typecore.mli b/compiler/ml/typecore.mli
index cf38d41009..bf0b76aa19 100644
--- a/compiler/ml/typecore.mli
+++ b/compiler/ml/typecore.mli
@@ -114,6 +114,7 @@ type error =
| Type_params_not_supported of Longident.t
| Field_access_on_dict_type
| Jsx_not_enabled
+ | Jsx_component_without_make of Longident.t
| Jsx_unboxed_props of type_expr
| Tagged_template_non_tag of type_expr
| Record_rest of Typecore_record_rest.error
diff --git a/compiler/ml/typetexp.ml b/compiler/ml/typetexp.ml
index 51a81a827f..6e1b04e5ca 100644
--- a/compiler/ml/typetexp.ml
+++ b/compiler/ml/typetexp.ml
@@ -785,6 +785,8 @@ let super_spellcheck ppf fold env lid =
| Longident.Lident s -> did_you_mean ppf (fun _ -> choices None s)
| Longident.Ldot (r, s) -> did_you_mean ppf (fun _ -> choices (Some r) s)
+let spellcheck_value ppf env lid = super_spellcheck ppf Env.fold_values env lid
+
let spellcheck ppf fold env lid =
let choices ~path name =
let env = fold (fun x xs -> x :: xs) path env [] in
@@ -890,7 +892,7 @@ let report_error env ppf = function
| other_ident ->
Format.fprintf ppf "The value %a can't be found" Printtyp.longident
other_ident);
- let did_spellcheck = super_spellcheck ppf Env.fold_values env lid in
+ let did_spellcheck = spellcheck_value ppf env lid in
(* For cases such as when the user refers to something that's a value with
a lowercase identifier in JS but a module in ReScript.
diff --git a/compiler/ml/typetexp.mli b/compiler/ml/typetexp.mli
index a4d0ee95cd..0665133ede 100644
--- a/compiler/ml/typetexp.mli
+++ b/compiler/ml/typetexp.mli
@@ -64,6 +64,10 @@ exception Error of Location.t * Env.t * error
val report_error : Env.t -> Format.formatter -> error -> unit
+val spellcheck_value : Format.formatter -> Env.t -> Longident.t -> bool
+(** Print a hint naming the values in scope whose names are close to the given
+ one. Returns whether it printed one. *)
+
(* Support for first-class modules. *)
val transl_modtype_longident :
(* from Typemod *)
diff --git a/tests/ERROR_VARIANTS.md b/tests/ERROR_VARIANTS.md
index fdf23273ee..472b668d00 100644
--- a/tests/ERROR_VARIANTS.md
+++ b/tests/ERROR_VARIANTS.md
@@ -252,6 +252,7 @@ Source: `type error` in [typecore.ml](../compiler/ml/typecore.ml).
| `Type_params_not_supported` | ✓ | `variant_spread_pattern_type_params.res` | Pattern-level variant spread (`| ...a as v`) where `a` has type params; typedecl path covered by `variant_spread_type_parameters.res`. |
| `Field_access_on_dict_type` | ✓ | `field_access_on_dict_type.res` | |
| `Jsx_not_enabled` | ☐ (needs harness flag) | — | typecore.ml, the `Pexp_jsx_element` cases of `iter_expression` and `type_expect_`. Fires when JSX is used without `-bs-jsx N`. The `super_errors` runner hard-codes `-bs-jsx 4` in `bscFlags`; adding a per-fixture opt-out (e.g. a `.opts` sidecar) would expose this. Until then, it's reachable in real code but blocked at the harness level. |
+| `Jsx_component_without_make` | ✓ | `jsx_component_without_make.res`, `jsx_component_without_make_typo.res` | A JSX tag naming a module that has no `make` value; the typo fixture pins the spellcheck hint. |
| `Jsx_unboxed_props` | ✓ | `jsx_unboxed_props.res`, `jsx_unboxed_props_spread.res` | A JSX element whose props have an `@unboxed` record type, given as attributes or as a spread. |
| `Tagged_template_non_tag` | ✓ | `tagged_template_non_tag.res` | Backtick tagged-template syntax used on a value whose type is not `taggedTemplate<'param, 'output>`. |
diff --git a/tests/build_tests/super_errors/expected/jsx_component_without_make.res.expected b/tests/build_tests/super_errors/expected/jsx_component_without_make.res.expected
new file mode 100644
index 0000000000..d2f18c861f
--- /dev/null
+++ b/tests/build_tests/super_errors/expected/jsx_component_without_make.res.expected
@@ -0,0 +1,15 @@
+
+ [1;31mWe've found a bug for you![0m
+ [36m/.../fixtures/jsx_component_without_make.res[0m:[2m24:10-18[0m
+
+ 22 [2m│[0m }
+ 23 [2m│[0m
+ [1;31m24[0m [2m│[0m let x = <[1;31mUi.Button[0m />
+ 25 [2m│[0m
+
+ The module [1;33mUi.Button[0m can't be used as a JSX component, because it doesn't define [1;33mmake[0m.
+
+ A component module defines its component as [1;33mmake[0m, for example:
+
+ [1;33m@react.component[0m
+ let make = (~label) => ...
\ No newline at end of file
diff --git a/tests/build_tests/super_errors/expected/jsx_component_without_make_typo.res.expected b/tests/build_tests/super_errors/expected/jsx_component_without_make_typo.res.expected
new file mode 100644
index 0000000000..43d24760a0
--- /dev/null
+++ b/tests/build_tests/super_errors/expected/jsx_component_without_make_typo.res.expected
@@ -0,0 +1,17 @@
+
+ [1;31mWe've found a bug for you![0m
+ [36m/.../fixtures/jsx_component_without_make_typo.res[0m:[2m25:10-15[0m
+
+ 23 [2m│[0m }
+ 24 [2m│[0m
+ [1;31m25[0m [2m│[0m let x = <[1;31mButton[0m label="a" />
+ 26 [2m│[0m
+
+ The module [1;33mButton[0m can't be used as a JSX component, because it doesn't define [1;33mmake[0m.
+
+ A component module defines its component as [1;33mmake[0m, for example:
+
+ [1;33m@react.component[0m
+ let make = (~label) => ...
+
+ [1;33mHint: Did you mean mak?[0m
\ No newline at end of file
diff --git a/tests/build_tests/super_errors/fixtures/jsx_component_without_make.res b/tests/build_tests/super_errors/fixtures/jsx_component_without_make.res
new file mode 100644
index 0000000000..435fceb50d
--- /dev/null
+++ b/tests/build_tests/super_errors/fixtures/jsx_component_without_make.res
@@ -0,0 +1,24 @@
+@@config({
+ flags: ["-bs-jsx", "4"],
+})
+
+module React = {
+ type element = Jsx.element
+ type componentLike<'props, 'return> = 'props => 'return
+ type component<'props> = Jsx.component<'props>
+ external component: componentLike<'props, element> => component<'props> = "%component_identity"
+
+ @module("react/jsx-runtime")
+ external jsx: (component<'props>, 'props) => element = "jsx"
+
+ type fragmentProps = {children?: element}
+ @module("react/jsx-runtime") external jsxFragment: component = "Fragment"
+}
+
+module Ui = {
+ module Button = {
+ let label = "x"
+ }
+}
+
+let x =
diff --git a/tests/build_tests/super_errors/fixtures/jsx_component_without_make_typo.res b/tests/build_tests/super_errors/fixtures/jsx_component_without_make_typo.res
new file mode 100644
index 0000000000..4c4cad6c86
--- /dev/null
+++ b/tests/build_tests/super_errors/fixtures/jsx_component_without_make_typo.res
@@ -0,0 +1,25 @@
+@@config({
+ flags: ["-bs-jsx", "4"],
+})
+
+module React = {
+ type element = Jsx.element
+ type componentLike<'props, 'return> = 'props => 'return
+ type component<'props> = Jsx.component<'props>
+ external component: componentLike<'props, element> => component<'props> = "%component_identity"
+
+ @module("react/jsx-runtime")
+ external jsx: (component<'props>, 'props) => element = "jsx"
+
+ type fragmentProps = {children?: element}
+ @module("react/jsx-runtime") external jsxFragment: component = "Fragment"
+
+ external string: string => element = "%identity"
+}
+
+module Button = {
+ @react.component
+ let mak = (~label) => React.string(label)
+}
+
+let x =