├── .github └── FUNDING.yml ├── .gitignore ├── .gitattributes ├── bsconfig.json ├── package.json ├── MIT-LICENSE ├── src ├── ReactCompat.resi └── ReactCompat.res ├── HISTORY.md ├── README.md └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["bloodyowl"] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .merlin 3 | .bsb.lock 4 | npm-debug.log 5 | /lib/bs/ 6 | /node_modules/ 7 | *.bs.js 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Tell github that .re and .rei files are Reason 2 | *.re linguist-language=Reason 3 | *.rei linguist-language=Reason 4 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rescript-react-compat", 3 | "reason": { 4 | "react-jsx": 3 5 | }, 6 | "sources": { 7 | "dir": "src", 8 | "subdirs": true 9 | }, 10 | "package-specs": [ 11 | { 12 | "module": "es6", 13 | "in-source": true 14 | } 15 | ], 16 | "suffix": ".bs.js", 17 | "bs-dependencies": ["@rescript/react"], 18 | "refmt": 3 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rescript-react-compat", 3 | "version": "3.0.4", 4 | "scripts": { 5 | "build": "bsb -make-world", 6 | "start": "bsb -make-world -w", 7 | "clean": "bsb -clean-world", 8 | "test": "bsb -make-world" 9 | }, 10 | "bugs": "https://github.com/bloodyowl/reason-react-compat/issues", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/bloodyowl/reason-react-compat.git" 14 | }, 15 | "keywords": [ 16 | "reason-react", 17 | "rescript", 18 | "react" 19 | ], 20 | "author": "bloodyowl ", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "@rescript/react": "^0.10.1 || ^0.11.0", 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0", 25 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" 26 | }, 27 | "devDependencies": { 28 | "@rescript/react": "^0.10.1", 29 | "bs-platform": "^8.3.0", 30 | "react": "^16.8.0", 31 | "react-dom": "^16.8.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016-2018 Various Authors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/ReactCompat.resi: -------------------------------------------------------------------------------- 1 | type rec component<'state, 'initialState, 'action> = { 2 | willReceiveProps: self<'state, 'action> => 'state, 3 | willUnmount: self<'state, 'action> => unit, 4 | didUpdate: oldNewSelf<'state, 'action> => unit, 5 | shouldUpdate: oldNewSelf<'state, 'action> => bool, 6 | willUpdate: oldNewSelf<'state, 'action> => unit, 7 | didMount: self<'state, 'action> => unit, 8 | initialState: unit => 'initialState, 9 | reducer: ('action, 'state) => update<'state, 'action>, 10 | render: self<'state, 'action> => React.element, 11 | } 12 | and update<'state, 'action> = 13 | | NoUpdate 14 | | Update('state) 15 | | SideEffects(self<'state, 'action> => unit) 16 | | UpdateWithSideEffects('state, self<'state, 'action> => unit) 17 | and self<'state, 'action> = { 18 | handle: 'payload. (('payload, self<'state, 'action>) => unit, 'payload) => unit, 19 | state: 'state, 20 | send: 'action => unit, 21 | onUnmount: (unit => unit) => unit, 22 | } 23 | and oldNewSelf<'state, 'action> = { 24 | oldSelf: self<'state, 'action>, 25 | newSelf: self<'state, 'action>, 26 | } 27 | 28 | let useRecordApi: component<'state, 'state, 'action> => React.element 29 | 30 | let component: component<'state, unit, 'action> 31 | 32 | let useMount: (unit => unit) => unit 33 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## 3.0.4 2 | 3 | Changes: 4 | 5 | - Add compat with React 18 (4b243ee) 6 | 7 | ## 3.0.3 8 | 9 | Changes: 10 | 11 | - Add compat with ReScript React 0.11.x (4591a8a) 12 | 13 | ## 3.0.2 14 | 15 | Fixes: 16 | 17 | - Update React & React DOM peer dependencies (5abe9ac) 18 | 19 | ## 3.0.1 20 | 21 | Fixes: 22 | 23 | - Fixed name in bsconfig (c92e2aa) 24 | 25 | ## 3.0.0 26 | 27 | Changes: 28 | 29 | - Move to ReScript (f91817a) 30 | - Move from reason-react to @rescript/react (f91817a) 31 | - Rename project to `rescript-react-compat` (f91817a) 32 | 33 | ## 2.0.0 34 | 35 | Changes: 36 | 37 | - Make effect management preact-safe (fe4db7b) 38 | 39 | ## 1.0.1 40 | 41 | Fixes: 42 | 43 | - Pass an up to date self to `willUnmount` (0f49043) 44 | 45 | ## 1.0.0 46 | 47 | Careful, this contains breaking changes! 48 | 49 | Features: 50 | 51 | - Add an `useMount` hook to easy migration (f39b6b1) 52 | 53 | Changes: 54 | 55 | - Bring our own implementation of `component` in order to remove unnecessary elements ([0f4a829](https://github.com/bloodyowl/reason-react-compat/commit/0f4a829ad70974e479fbc1262b28903d0d1a0ac6), motivations in the description) 56 | 57 | Fixes: 58 | 59 | - Pass an up-to-date self to `handle` handlers (b68c8bd) 60 | 61 | ## 0.4.0 62 | 63 | Changes: 64 | 65 | - Use useEffect instead of useLayoutEffect 66 | 67 | ## 0.3.0 68 | 69 | Fixes: 70 | 71 | - Remove buggy condition that'd prevent re-render 72 | 73 | ## 0.2.0 74 | 75 | Changes: 76 | 77 | - Remove namespace 78 | 79 | ## 0.1.1 80 | 81 | Fixes: 82 | 83 | - Fixed some typos in the config 84 | 85 | ## 0.1.0 86 | 87 | Initial release 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rescript-react-compat 2 | 3 | > An alternative upgrade path for ReasonReact 4 | 5 | ## Installation 6 | 7 | ```console 8 | $ yarn add rescript-react-compat 9 | ``` 10 | 11 | or 12 | 13 | ```console 14 | $ npm install --save rescript-react-compat 15 | ``` 16 | 17 | Then add `rescript-react-compat` to your `bsconfig.json` `bs-dependencies` field. 18 | 19 | ## ReactCompat.useRecordApi 20 | 21 | Enables you to wrap your existing `ReasonReact.statelessComponent` and `ReasonReact.reducerComponent` through a React hook. 22 | 23 | ```reason 24 | [@react.component] 25 | let make = () => { 26 | ReactCompat.useRecordApi({ 27 | ...ReactCompat.component, 28 | render: _ => 29 |
"Helloworld!"->ReasonReact.string
30 | }) 31 | } 32 | ``` 33 | 34 | ### Upgrade path 35 | 36 | #### Stateless components 37 | 38 | For implementation files (`.re`) 39 | 40 | ```diff 41 | -let component = ReasonReact.statelessComponent("MyComponent"); 42 | 43 | +[@react.component] 44 | - let make = _ => { 45 | + let make = () => { 46 | + ReactCompat.useRecordApi( 47 | { 48 | - ...component, 49 | + ...ReactCompat.component, 50 | render: _ => 51 |
"Helloworld!"->ReasonReact.string
52 | } 53 | + ) 54 | } 55 | ``` 56 | 57 | For interface files (`.rei`) 58 | 59 | ```diff 60 | 61 | +[@react.component] 62 | - let make = 'a => 63 | + let make = unit => 64 | - ReasonReact.component( 65 | - ReasonReact.stateless, 66 | - ReasonReact.noRetainedProps, 67 | - ReasonReact.actionless 68 | - ); 69 | + React.element; 70 | ``` 71 | 72 | #### Reducer components 73 | 74 | For implementation files (`.re`) 75 | 76 | ```diff 77 | type action = | Tick; 78 | 79 | type state = {count: int}; 80 | 81 | -let component = ReasonReact.reducerComponent("MyComponent"); 82 | 83 | +[@react.component] 84 | - let make = _ => { 85 | + let make = () => { 86 | + ReactCompat.useRecordApi( 87 | { 88 | - ...component, 89 | + ...ReactCompat.component, 90 | /* some lifecycle */ 91 | render: _ => 92 |
"Helloworld!"->ReasonReact.string
93 | } 94 | + ) 95 | } 96 | ``` 97 | 98 | You'll also need to rename: 99 | 100 | - `ReasonReact.Update` -> `Update` 101 | - `ReasonReact.UpdateWithSideEffects` -> `UpdateWithSideEffects` 102 | - `ReasonReact.SideEffects` -> `SideEffects` 103 | - `ReasonReact.NoUpdate` -> `NoUpdate` 104 | 105 | For interface files (`.rei`) 106 | 107 | ```diff 108 | -type state; 109 | 110 | -type action; 111 | 112 | +[@react.component] 113 | - let make = 'a => 114 | + let make = unit => 115 | - ReasonReact.component( 116 | - state, 117 | - ReasonReact.noRetainedProps, 118 | - action 119 | - ); 120 | + React.element; 121 | ``` 122 | 123 | ## Acknowledgments 124 | 125 | Thnks @rickyvetter for the original idea and help through the process 126 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@rescript/react@^0.10.1": 6 | version "0.10.1" 7 | resolved "https://registry.yarnpkg.com/@rescript/react/-/react-0.10.1.tgz#ddce66ba664a104354d559c350ca4ebf17ab5a26" 8 | integrity sha512-5eIfGnV1yhjv03ktK6fQ6iEfsZKXKXXrq5hx4+ngEY4R/RU8o/oH9ne375m9RJMugV/jsE8hMoEeSSg2YQy3Ag== 9 | 10 | bs-platform@^8.3.0: 11 | version "8.4.2" 12 | resolved "https://registry.yarnpkg.com/bs-platform/-/bs-platform-8.4.2.tgz#778dabd1dfb3bc95e0086c58dabae74e4ebdee8a" 13 | integrity sha512-9q7S4/LLV/a68CweN382NJdCCr/lOSsJR3oQYnmPK98ChfO/AdiA3lYQkQTp6T+U0I5Z5RypUAUprNstwDtMDQ== 14 | 15 | "js-tokens@^3.0.0 || ^4.0.0": 16 | version "4.0.0" 17 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 18 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 19 | 20 | loose-envify@^1.1.0, loose-envify@^1.4.0: 21 | version "1.4.0" 22 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 23 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 24 | dependencies: 25 | js-tokens "^3.0.0 || ^4.0.0" 26 | 27 | object-assign@^4.1.1: 28 | version "4.1.1" 29 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 30 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 31 | 32 | prop-types@^15.6.2: 33 | version "15.7.2" 34 | resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" 35 | integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== 36 | dependencies: 37 | loose-envify "^1.4.0" 38 | object-assign "^4.1.1" 39 | react-is "^16.8.1" 40 | 41 | react-dom@^16.8.0: 42 | version "16.8.6" 43 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" 44 | integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== 45 | dependencies: 46 | loose-envify "^1.1.0" 47 | object-assign "^4.1.1" 48 | prop-types "^15.6.2" 49 | scheduler "^0.13.6" 50 | 51 | react-is@^16.8.1: 52 | version "16.8.6" 53 | resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" 54 | integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== 55 | 56 | react@^16.8.0: 57 | version "16.8.6" 58 | resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" 59 | integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== 60 | dependencies: 61 | loose-envify "^1.1.0" 62 | object-assign "^4.1.1" 63 | prop-types "^15.6.2" 64 | scheduler "^0.13.6" 65 | 66 | scheduler@^0.13.6: 67 | version "0.13.6" 68 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" 69 | integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== 70 | dependencies: 71 | loose-envify "^1.1.0" 72 | object-assign "^4.1.1" 73 | -------------------------------------------------------------------------------- /src/ReactCompat.res: -------------------------------------------------------------------------------- 1 | type rec component<'state, 'initialState, 'action> = { 2 | willReceiveProps: self<'state, 'action> => 'state, 3 | willUnmount: self<'state, 'action> => unit, 4 | didUpdate: oldNewSelf<'state, 'action> => unit, 5 | shouldUpdate: oldNewSelf<'state, 'action> => bool, 6 | willUpdate: oldNewSelf<'state, 'action> => unit, 7 | didMount: self<'state, 'action> => unit, 8 | initialState: unit => 'initialState, 9 | reducer: ('action, 'state) => update<'state, 'action>, 10 | render: self<'state, 'action> => React.element, 11 | } 12 | and update<'state, 'action> = 13 | | NoUpdate 14 | | Update('state) 15 | | SideEffects(self<'state, 'action> => unit) 16 | | UpdateWithSideEffects('state, self<'state, 'action> => unit) 17 | and self<'state, 'action> = { 18 | handle: 'payload. (('payload, self<'state, 'action>) => unit, 'payload) => unit, 19 | state: 'state, 20 | send: 'action => unit, 21 | onUnmount: (unit => unit) => unit, 22 | } 23 | and oldNewSelf<'state, 'action> = { 24 | oldSelf: self<'state, 'action>, 25 | newSelf: self<'state, 'action>, 26 | } 27 | 28 | @ocaml.doc(" This is not exposed, only used internally so that useReducer can 29 | return side-effects to run later. 30 | ") 31 | type fullState<'state, 'action> = { 32 | sideEffects: ref => unit>>, 33 | state: ref<'state>, 34 | } 35 | 36 | let useRecordApi = componentSpec => { 37 | let initialState = React.useMemo0(componentSpec.initialState) 38 | let unmountSideEffects = React.useRef([]) 39 | 40 | let ({state, sideEffects}, send) = React.useReducer((fullState, action) => 41 | @ocaml.doc(" 42 | Keep fullState.state in a ref so that willReceiveProps can alter it. 43 | It's the only place we let it be altered. 44 | Keep fullState.sideEffects so that they can be cleaned without a state update. 45 | It's important that the reducer only **creates new refs** an doesn't alter them, 46 | otherwise React wouldn't be able to rollback state in concurrent mode. 47 | ") 48 | switch componentSpec.reducer(action, fullState.state.contents) { 49 | | NoUpdate => fullState /* useReducer returns of the same value will not rerender the component */ 50 | | Update(state) => {...fullState, state: ref(state)} 51 | | SideEffects(sideEffect) => { 52 | ...fullState, 53 | sideEffects: ref(Js.Array.concat(fullState.sideEffects.contents, [sideEffect])), 54 | } 55 | | UpdateWithSideEffects(state, sideEffect) => { 56 | sideEffects: ref(Js.Array.concat(fullState.sideEffects.contents, [sideEffect])), 57 | state: ref(state), 58 | } 59 | } 60 | , {sideEffects: ref([]), state: ref(initialState)}) 61 | 62 | @ocaml.doc(" This is the temp self for willReceiveProps ") 63 | let rec self = { 64 | handle: (fn, payload) => fn(payload, self), 65 | send: send, 66 | state: state.contents, 67 | onUnmount: sideEffect => Js.Array.push(sideEffect, unmountSideEffects.current)->ignore, 68 | } 69 | 70 | let upToDateSelf = React.useRef(self) 71 | 72 | let hasBeenCalled = React.useRef(false) 73 | 74 | @ocaml.doc(" There might be some potential issues with willReceiveProps, 75 | treat it as it if was getDerivedStateFromProps. ") 76 | (state := componentSpec.willReceiveProps(self)) 77 | 78 | let self = { 79 | handle: (fn, payload) => fn(payload, upToDateSelf.current), 80 | send: send, 81 | state: state.contents, 82 | onUnmount: sideEffect => Js.Array.push(sideEffect, unmountSideEffects.current)->ignore, 83 | } 84 | 85 | let oldSelf = React.useRef(self) 86 | 87 | let _mountUnmountEffect = React.useEffect0(() => { 88 | componentSpec.didMount(self) 89 | Some( 90 | () => { 91 | Js.Array.forEach(fn => fn(), unmountSideEffects.current) 92 | 93 | /* shouldn't be needed but like - better safe than sorry? */ 94 | unmountSideEffects.current = [] 95 | componentSpec.willUnmount(upToDateSelf.current) 96 | }, 97 | ) 98 | }) 99 | 100 | let _didUpdateEffect = React.useEffect(() => { 101 | if hasBeenCalled.current { 102 | componentSpec.didUpdate({oldSelf: oldSelf.current, newSelf: self}) 103 | } else { 104 | hasBeenCalled.current = true 105 | } 106 | oldSelf.current = self 107 | None 108 | }) 109 | 110 | @ocaml.doc(" Because sideEffects are only added through a **new** ref, 111 | we can use the ref itself as the dependency. This way the 112 | effect doesn't re-run after a cleanup. 113 | ") 114 | React.useEffect1(() => { 115 | if Js.Array.length(sideEffects.contents) > 0 { 116 | let sideEffectsToRun = Js.Array.sliceFrom(0, sideEffects.contents) 117 | sideEffects := [] 118 | Js.Array.forEach(func => func(self), sideEffectsToRun) 119 | } 120 | None 121 | }, [sideEffects]) 122 | 123 | let mostRecentAllowedRender = React.useRef(React.useMemo0(() => componentSpec.render(self))) 124 | 125 | upToDateSelf.current = self 126 | 127 | if ( 128 | hasBeenCalled.current && 129 | componentSpec.shouldUpdate({ 130 | oldSelf: oldSelf.current, 131 | newSelf: self, 132 | }) 133 | ) { 134 | componentSpec.willUpdate({oldSelf: oldSelf.current, newSelf: self}) 135 | mostRecentAllowedRender.current = componentSpec.render(self) 136 | } 137 | mostRecentAllowedRender.current 138 | } 139 | 140 | module Defaults = { 141 | let anyToUnit = _ => () 142 | let anyToTrue = _ => true 143 | let willReceivePropsDefault: self<'state, 'action> => 'state = ({state}) => state 144 | let renderDefault = _self => React.string("RenderNotImplemented") 145 | let initialStateDefault = () => () 146 | let reducerDefault: ('action, 'state) => update<'state, 'action> = (_action, _state) => NoUpdate 147 | } 148 | 149 | let component: component<'state, 'initialState, 'action> = { 150 | didMount: Defaults.anyToUnit, 151 | willReceiveProps: Defaults.willReceivePropsDefault, 152 | didUpdate: Defaults.anyToUnit, 153 | willUnmount: Defaults.anyToUnit, 154 | willUpdate: Defaults.anyToUnit, 155 | shouldUpdate: Defaults.anyToTrue, 156 | render: Defaults.renderDefault, 157 | initialState: Defaults.initialStateDefault, 158 | reducer: Defaults.reducerDefault, 159 | } 160 | 161 | let useMount = func => 162 | React.useEffect0(() => { 163 | func() 164 | None 165 | }) 166 | --------------------------------------------------------------------------------