diff --git a/Makefile b/Makefile index 7a88146d6..d5a930595 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,9 @@ format-check: ## Checks if format is correct .PHONY: install install: ## Update the package dependencies when new deps are added to dune-project @opam install . --deps-only --with-test +# --force is needed because we are installing react@19 while other dependencies +# require react@18. It's a good workaround to bypass the npm validation error +# and test the rc versions of React @npm install --force .PHONY: init diff --git a/demo/dune b/demo/dune index 3f1c1ac86..851bbbc10 100644 --- a/demo/dune +++ b/demo/dune @@ -3,7 +3,7 @@ (alias melange-app) (module_systems (es6 mjs)) - (libraries reason-react jest melange.belt melange.dom) + (libraries reason-react melange.belt melange.dom) (runtime_deps index.html) (preprocess (pps melange.ppx reason-react-ppx))) diff --git a/ppx/reason_react_ppx.ml b/ppx/reason_react_ppx.ml index afa7dc365..ca7c99e83 100644 --- a/ppx/reason_react_ppx.ml +++ b/ppx/reason_react_ppx.ml @@ -637,8 +637,10 @@ let jsxMapper = let expr = mapper#expression ctxt expr in match expr.pexp_desc with | Pexp_fun (Labelled "key", _, _, _) | Pexp_fun (Optional "key", _, _, _) -> - Location.raise_errorf ~loc:expr.pexp_loc - ("~key cannot be accessed from the component props. Please set the key where the component is being used.") + raise + (Invalid_argument + "Key cannot be accessed inside of a component. Don't worry - you \ + can always key a component from its parent!") | Pexp_fun ( ((Optional label | Labelled label) as arg), default, diff --git a/src/React.re b/src/React.re index ba02b4222..ae8cd326e 100644 --- a/src/React.re +++ b/src/React.re @@ -885,16 +885,24 @@ external startTransition: ([@mel.uncurry] (unit => unit)) => unit = external useDebugValue: ('value, ~format: 'value => string=?, unit) => unit = "useDebugValue"; +[@mel.module "react"] +external act: (unit => unit) => Js.Promise.t(unit) = "act"; +[@mel.module "react"] +external actAsync: (unit => Js.Promise.t(unit)) => Js.Promise.t(unit) = + "act"; + module Experimental = { /* This module is used to bind to APIs for future versions of React. There is no guarantee of backwards compatibility or stability. */ + /* https://react.dev/reference/react/use */ [@mel.module "react"] external usePromise: Js.Promise.t('a) => 'a = "use"; [@mel.module "react"] external useContext: Context.t('a) => 'a = "use"; + /* https://react.dev/reference/react/useTransition */ [@mel.module "react"] external useTransitionAsync: unit => (bool, callbackAsync(callbackAsync(unit, unit), unit)) = "useTransition"; - /* https://es.react.dev/reference/react/useOptimistic */ + /* https://react.dev/reference/react/useOptimistic */ [@mel.module "react"] external useOptimistic: ('state, ('state, 'optimisticValue) => 'state) => diff --git a/src/React.rei b/src/React.rei index 372337618..66b4de0ee 100644 --- a/src/React.rei +++ b/src/React.rei @@ -573,6 +573,12 @@ external startTransition: ([@mel.uncurry] (unit => unit)) => unit = external useTransition: unit => (bool, callback(callback(unit, unit), unit)) = "useTransition"; +[@mel.module "react"] +external act: (unit => unit) => Js.Promise.t(unit) = "act"; +[@mel.module "react"] +external actAsync: (unit => Js.Promise.t(unit)) => Js.Promise.t(unit) = + "act"; + module Experimental: { /* This module is used to bind to APIs for future versions of React. There is no guarantee of backwards compatibility or stability. */ [@mel.module "react"] external usePromise: Js.Promise.t('a) => 'a = "use"; diff --git a/src/ReactDOM.re b/src/ReactDOM.re index 92b7c4d6c..bedb622f2 100644 --- a/src/ReactDOM.re +++ b/src/ReactDOM.re @@ -652,7 +652,7 @@ type domProps = { [@mel.optional] acceptCharset: option(string), [@mel.optional] - action: option(string), + action: option(string), /* uri */ [@mel.optional] allowFullScreen: option(bool), [@mel.optional] diff --git a/src/ReactDOMTestUtils.re b/src/ReactDOMTestUtils.re index 80615b4ec..24dafbff3 100644 --- a/src/ReactDOMTestUtils.re +++ b/src/ReactDOMTestUtils.re @@ -5,6 +5,7 @@ let undefined: undefined = Js.Undefined.empty; [@mel.module "react"] external reactAct: ((. unit) => undefined) => unit = "act"; +[@deprecated "use React.act instead"] let act: (unit => unit) => unit = func => { let reactFunc = @@ -19,6 +20,7 @@ let act: (unit => unit) => unit = external reactActAsync: ((. unit) => Js.Promise.t('a)) => Js.Promise.t(unit) = "act"; +[@deprecated "use React.actAsync instead"] let actAsync = func => { let reactFunc = (.) => { @@ -27,35 +29,73 @@ let actAsync = func => { reactActAsync(reactFunc); }; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isElement: 'element => bool = "isElement"; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isElementOfType: ('element, React.component('props)) => bool = "isElementOfType"; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isDOMComponent: 'element => bool = "isDOMComponent"; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isCompositeComponent: 'element => bool = "isCompositeComponent"; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isCompositeComponentWithType: ('element, React.component('props)) => bool = "isCompositeComponentWithType"; module Simulate = { - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external click: Dom.element => unit = "click"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external clickWithEvent: (Dom.element, 'event) => unit = "click"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external change: Dom.element => unit = "change"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external blur: Dom.element => unit = "blur"; + [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] external changeWithEvent: (Dom.element, 'event) => unit = "change"; + + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] let changeWithValue = (element, value) => { let event = { "target": { @@ -64,6 +104,10 @@ module Simulate = { }; changeWithEvent(element, event); }; + + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] let changeWithChecked = (element, value) => { let event = { "target": { @@ -72,11 +116,23 @@ module Simulate = { }; changeWithEvent(element, event); }; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external canPlay: Dom.element => unit = "canPlay"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external timeUpdate: Dom.element => unit = "timeUpdate"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external ended: Dom.element => unit = "ended"; [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] external focus: Dom.element => unit = "focus"; @@ -101,7 +157,9 @@ external body: Dom.document => option(Dom.element) = "body"; [@mel.send] external createElement: (Dom.document, string) => Dom.element = "createElement"; + [@mel.send] external remove: Dom.element => unit = "remove"; + [@mel.send] external appendChild: (Dom.element, Dom.element) => Dom.element = "appendChild"; @@ -111,19 +169,35 @@ let querySelectorAll = (element, string) => { }; module DOM = { - [@mel.return nullable] [@mel.get] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] + [@mel.return nullable] + [@mel.get] external value: Dom.element => option(string) = "value"; + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] let findBySelector = (element, selector) => querySelector(element, selector); + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] let findByAllSelector = (element, selector) => querySelectorAll(element, selector); + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] let findBySelectorAndTextContent = (element, selector, content) => querySelectorAll(element, selector) |> Array.find_opt(node => node->textContent === content); + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] let findBySelectorAndPartialTextContent = (element, selector, content) => querySelectorAll(element, selector) |> Array.find_opt(node => @@ -131,6 +205,9 @@ module DOM = { ); }; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let prepareContainer = (container: ref(option(Dom.element)), ()) => { let containerElement = document->createElement("div"); let _: option(_) = @@ -138,11 +215,17 @@ let prepareContainer = (container: ref(option(Dom.element)), ()) => { container := Some(containerElement); }; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let cleanupContainer = (container: ref(option(Dom.element)), ()) => { let _: option(_) = Option.map(remove, container^); container := None; }; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let getContainer = container => { container.contents->Option.get; }; diff --git a/src/ReactDOMTestUtils.rei b/src/ReactDOMTestUtils.rei index 5f67f3345..bfab45e81 100644 --- a/src/ReactDOMTestUtils.rei +++ b/src/ReactDOMTestUtils.rei @@ -1,60 +1,145 @@ -let act: (unit => unit) => unit; +let act: [@deprecated "use React.act instead"] ((unit => unit) => unit); -let actAsync: (unit => Js.Promise.t('a)) => Js.Promise.t(unit); +let actAsync: + [@deprecated "use React.actAsync instead"] ( + (unit => Js.Promise.t('a)) => Js.Promise.t(unit) + ); [@mel.module "react-dom/test-utils"] +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] external isElement: 'element => bool = "isElement"; [@mel.module "react-dom/test-utils"] +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] external isElementOfType: ('element, React.component('props)) => bool = "isElementOfType"; [@mel.module "react-dom/test-utils"] +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] external isDOMComponent: 'element => bool = "isDOMComponent"; [@mel.module "react-dom/test-utils"] +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] external isCompositeComponent: 'element => bool = "isCompositeComponent"; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] [@mel.module "react-dom/test-utils"] external isCompositeComponentWithType: ('element, React.component('props)) => bool = "isCompositeComponentWithType"; module Simulate: { - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external click: Dom.element => unit = "click"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external clickWithEvent: (Dom.element, 'event) => unit = "click"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external change: Dom.element => unit = "change"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external blur: Dom.element => unit = "blur"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external changeWithEvent: (Dom.element, 'event) => unit = "change"; let changeWithValue: (Dom.element, string) => unit; let changeWithChecked: (Dom.element, bool) => unit; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external canPlay: Dom.element => unit = "canPlay"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external timeUpdate: Dom.element => unit = "timeUpdate"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external ended: Dom.element => unit = "ended"; - [@mel.module "react-dom/test-utils"] [@mel.scope "Simulate"] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." + ] + [@mel.module "react-dom/test-utils"] + [@mel.scope "Simulate"] external focus: Dom.element => unit = "focus"; }; module DOM: { - [@mel.return nullable] [@mel.get] + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] + [@mel.return nullable] + [@mel.get] external value: Dom.element => option(string) = "value"; - let findBySelector: (Dom.element, string) => option(Dom.element); - let findByAllSelector: (Dom.element, string) => array(Dom.element); + let findBySelector: + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] ( + (Dom.element, string) => option(Dom.element) + ); + let findByAllSelector: + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] ( + (Dom.element, string) => array(Dom.element) + ); let findBySelectorAndTextContent: - (Dom.element, string, string) => option(Dom.element); + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] ( + (Dom.element, string, string) => option(Dom.element) + ); let findBySelectorAndPartialTextContent: - (Dom.element, string, string) => option(Dom.element); + [@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-webapi instead." + ] ( + (Dom.element, string, string) => option(Dom.element) + ); }; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let prepareContainer: (Stdlib.ref(option(Dom.element)), unit) => unit; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let cleanupContainer: (Stdlib.ref(option(Dom.element)), unit) => unit; +[@deprecated + "ReactDOMTestUtils is deprecated, and will be removed in next version. Please use melange-testing-library instead." +] let getContainer: Stdlib.ref(option(Dom.element)) => Dom.element; diff --git a/test/Form__test.re b/test/Form__test.re index 256081650..dbbe7eee2 100644 --- a/test/Form__test.re +++ b/test/Form__test.re @@ -87,7 +87,7 @@ module App = { React.useState(() => [ { - text: "Hola!", + text: {j|¡Hola!|j}, sending: false, key: 1, }, @@ -136,7 +136,7 @@ describe("Form with useOptimistic", () => { let container = ReactTestingLibrary.render(); ReactTestingLibrary.actAsync(() => { - let.await _ = findByString("Hola!", container); + let.await _ = findByString({j|¡Hola!|j}, container); let.await button = findByString("Enviar", container); let.await input = findByPlaceholderText("message", container); diff --git a/test/React__test.re b/test/React__test.re index e7b624355..184716112 100644 --- a/test/React__test.re +++ b/test/React__test.re @@ -47,6 +47,7 @@ module DummyContext = { [@mel.get] external tagName: Dom.element => string = "tagName"; [@mel.get] external innerHTML: Dom.element => string = "innerHTML"; +[@mel.set] external setInnerHTML: (Dom.element, string) => unit = "innerHTML"; let getByRole = (role, container) => { ReactTestingLibrary.getByRole(~matcher=`Str(role), container); @@ -62,6 +63,24 @@ let getByTag = (tag, container) => { [@mel.send] external getAttribute: (Dom.element, string) => option(string) = "getAttribute"; +[@mel.set] external setTitle: (Dom.element, string) => unit = "title"; +[@mel.get] external getTitle: Dom.element => string = "title"; + +let (let.await) = (p, f) => Js.Promise.then_(f, p); + +external createElement: string => Dom.element = "document.createElement"; +[@mel.send] +external appendChild: (Dom.element, Dom.element) => unit = "appendChild"; +external document: Dom.element = "document"; +external body: Dom.element = "document.body"; +external querySelector: (string, Dom.element) => option(Dom.element) = + "document.querySelector"; + +[@mel.new] +external mouseEvent: (string, Js.t('a)) => Dom.event = "MouseEvent"; + +[@mel.send] +external dispatchEvent: (Dom.element, Dom.event) => unit = "dispatchEvent"; describe("React", () => { test("can render DOM elements", () => { @@ -233,6 +252,122 @@ describe("React", () => { expect(image->getAttribute("src"))->toEqual(Some("https://foo.png")); }); + module Counter = { + [@react.component] + let make = () => { + let (count, setCount) = React.Uncurried.useState(() => 0); + + React.useEffect1( + () => { + document->setTitle( + "You clicked " ++ Int.to_string(count) ++ " times", + ); + None; + }, + [|count|], + ); + +
+ + {React.string(string_of_int(count))} +
; + }; + }; + + testPromise("act", finish => { + /* This test doesn't use ReactTestingLibrary to test the act API, and the code comes from + https://react.dev/reference/react/act example */ + + let container: Dom.element = createElement("div"); + body->appendChild(container); + + let.await () = + React.act(() => { + let root = ReactDOM.Client.createRoot(container); + ReactDOM.Client.render(root, ); + }); + + let valueElement = querySelector(".Value", container); + switch (valueElement) { + | Some(value) => expect(value->innerHTML)->toBe("0") + | None => failwith("Can't find 'Value' element") + }; + + let title = getTitle(document); + expect(title)->toBe("You clicked 0 times"); + + let.await () = + React.act(() => { + let buttonElement = querySelector(".Increment", container); + switch (buttonElement) { + | Some(button) => + dispatchEvent(button, mouseEvent("click", {"bubbles": true})) + | None => failwith("Can't find 'Increment' button") + }; + }); + + let valueElement = querySelector(".Value", container); + switch (valueElement) { + | Some(value) => expect(value->innerHTML)->toBe("1") + | None => failwith("Can't find 'Value' element") + }; + + let title = getTitle(document); + expect(title)->toBe("You clicked 1 times"); + + finish(); + }); + + testPromise("actAsync", finish => { + /* This test doesn't use ReactTestingLibrary to test the act API, and the code comes from + https://react.dev/reference/react/act example */ + + body->setInnerHTML(""); + let container: Dom.element = createElement("div"); + body->appendChild(container); + + let.await () = + React.actAsync(() => { + let root = ReactDOM.Client.createRoot(container); + ReactDOM.Client.render(root, ); + Js.Promise.resolve(); + }); + + let valueElement = querySelector(".Value", container); + switch (valueElement) { + | Some(value) => expect(value->innerHTML)->toBe("0") + | None => failwith("Can't find 'Value' element") + }; + + let title = getTitle(document); + expect(title)->toBe("You clicked 0 times"); + + let.await () = + React.actAsync(() => { + let buttonElement = querySelector(".Increment", container); + switch (buttonElement) { + | Some(button) => + dispatchEvent(button, mouseEvent("click", {"bubbles": true})) + | None => failwith("Can't find 'Increment' button") + }; + Js.Promise.resolve(); + }); + + let valueElement = querySelector(".Value", container); + switch (valueElement) { + | Some(value) => expect(value->innerHTML)->toBe("1") + | None => failwith("Can't find 'Value' element") + }; + + let title = getTitle(document); + expect(title)->toBe("You clicked 1 times"); + + finish(); + }); + test("ErrorBoundary + Suspense", () => { [%mel.raw "console.error = () => {}"] |> ignore; diff --git a/test/Ref__test.re b/test/Ref__test.re index 9dca08716..223a5f348 100644 --- a/test/Ref__test.re +++ b/test/Ref__test.re @@ -25,5 +25,11 @@ describe("ref", () => { ); let button = getByRole("FancyButton", container); expect(button->innerHTML)->toBe("Click me"); + let content = + switch (Js.Nullable.toOption(domRef.current)) { + | Some(element) => element->innerHTML + | None => failwith("No element found") + }; + expect(content)->toBe("Click me"); }) });