├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── assets └── demo.gif ├── bsconfig.json ├── package.json └── src ├── connectors.re ├── connectors.rei ├── extension.re ├── reduxJsStore.re ├── types.re ├── utilities.re └── utilities.rei /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | lib/bs/ 4 | lib/js/ 5 | node_modules/ 6 | .merlin 7 | .bsb.lock 8 | package-lock.json 9 | .tgz 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | assets/ 2 | 3 | lib/bs/ 4 | lib/js/ 5 | .merlin 6 | .bsb.lock 7 | .tgz 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Taras Vozniuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## reductive-dev-tools 2 | 3 | [![VERSION](https://img.shields.io/npm/v/reductive-dev-tools)](https://www.npmjs.com/package/reductive-dev-tools) 4 | [![LICENSE](https://img.shields.io/github/license/ambientlight/reductive-dev-tools)](https://github.com/ambientlight/reductive-dev-tools/blob/master/LICENSE) 5 | [![ISSUES](https://img.shields.io/github/issues/ambientlight/reductive-dev-tools)](https://github.com/ambientlight/reductive-dev-tools/issues) 6 | 7 | [reductive](https://github.com/reasonml-community/reductive) and [reason-react](https://github.com/reasonml/reason-react) reducer component integration with [redux-devtools-extension](https://github.com/zalmoxisus/redux-devtools-extension). 8 | 9 | Requires **bucklescript 8.x.x**, for lower versions of bucklescript, please rely on **2.0.0**. 10 | 11 | 12 | ![image](assets/demo.gif) 13 | 14 | ## Installation 15 | 16 | 1. with npm: 17 | ```bash 18 | npm install --save-dev reductive-dev-tools 19 | ``` 20 | 21 | 2. add `reductive-dev-tools` to your "bs-dependencies" inside `bsconfig.json`. 22 | 3. add `-bs-g` into `"bsc-flags"` of your **bsconfig.json** to have variant and record field names available inside extension. 23 | 24 | **Peer depedencies** 25 | reason-react, reductive, redux-devtools-extension, redux (redux-devtools-extension's peer dep.) should be also installed. 26 | 27 | ## Usage 28 | Utilize provided store enhancer `ReductiveDevTools.Connectors.enhancer` for **reductive** or `ReductiveDevTools.Connectors.useReducer` for **reason-react hooks** (jsx3). 29 | 30 | You need to pass devtools extension [options](#options) as `~options` and action creator that builds action when state update is dispatched from the monitor as `~devToolsUpdateActionCreator`. Additionally you can also pass `~stateSerializer` and `~actionSerializer` to override default serialization behaviour. Take a look at [Serialization](#serialization) to see if you need it. 31 | 32 | #### reductive 33 | 34 | ```reason 35 | let storeCreator = 36 | ReductiveDevTools.Connectors.enhancer( 37 | ~options=ReductiveDevTools.Extension.enhancerOptions( 38 | ~name=__MODULE__, 39 | ~actionCreators={ 40 | "actionYouCanDispatchFromMonitor": (value: int) => `YourActionOfChoice(value) 41 | |. ReductiveDevTools.Utilities.Serializer.serializeAction 42 | }, 43 | ()), 44 | ~devToolsUpdateActionCreator=(devToolsState) => `DevToolsUpdate(devToolsState), 45 | () 46 | ) 47 | @@ Reductive.Store.create; 48 | ``` 49 | 50 | #### React Hooks useReducer (jsx3) 51 | 52 | ```reason 53 | let (state, send) = ReductiveDevTools.Connectors.useReducer( 54 | ~options=ReductiveDevTools.Extension.enhancerOptions( 55 | ~name=__MODULE__, 56 | ~actionCreators={ 57 | "actionYouCanDispatchFromMonitor": (value: int) => `YourActionOfChoice(value) 58 | |. ReductiveDevTools.Utilities.Serializer.serializeAction 59 | }, 60 | ()), 61 | ~devToolsUpdateActionCreator=(devToolsState) => `DevToolsUpdate(devToolsState), 62 | ~reducer, 63 | ~initial=yourInitialState, 64 | ()); 65 | ``` 66 | 67 | #### Usage with ReactReason legacy reducer component (jsx2) 68 | 69 | No longer supported. Please install latest from 0.x: 70 | 71 | ``` 72 | npm install --save-dev reductive-dev-tools@0.2.6 73 | ``` 74 | 75 | And refer to [old documentation](https://github.com/ambientlight/reductive-dev-tools/blob/dac77af64763d1aaed584a405c8caeb8b8597272/README.md#usage-with-reactreason-reducer-component). 76 | 77 | ## Serialization 78 | 79 | ### Actions 80 | With bucklescript 8 release, variants are js-objects at runtime, so this extension no longer serializes actions. By default it only extracts variant name from `Symbol(name)` when `-bs-g` flag is set in `bsconfig.json`. If needed, you can define your custom serialization by passing `~actionSerializer` like: 81 | 82 | ```reason 83 | ReductiveDevTools.Connectors.enhancer( 84 | ~options=ReductiveDevTools.Extension.enhancerOptions( 85 | ~name=__MODULE__, 86 | ()), 87 | ~actionSerializer={ 88 | serialize: obj => { 89 | // your serialization logic 90 | obj 91 | }, 92 | deserialize: obj => { 93 | // your deserialization logic 94 | obj 95 | } 96 | }, 97 | ()) 98 | ``` 99 | 100 | There are few caveats that apply to default serialization though. 101 | 102 | 1. Make sure to add `-bs-g` into `"bsc-flags"` of your **bsconfig.json** to have variant names available. 103 | 2. Variants with constructors should be prefered to plain (`SomeAction(unit)` to `SomeAction`) since plain varaints do no carry debug metedata(in symbols) with them (represented as numbers in js). 104 | 105 | ### State 106 | 107 | There is no serialization (no longer) applied to state by default. If needed, you can define your custom serialization by passing `~stateSerializer`: 108 | 109 | ```reason 110 | ReductiveDevTools.Connectors.enhancer( 111 | ~options=ReductiveDevTools.Extension.enhancerOptions( 112 | ~name=__MODULE__, 113 | ()), 114 | ~stateSerializer={ 115 | serialize: obj => { 116 | // your serialization logic 117 | obj 118 | }, 119 | deserialize: obj => { 120 | // your deserialization logic 121 | obj 122 | } 123 | }, 124 | ()) 125 | ``` 126 | 127 | ## Options 128 | 129 | ```reason 130 | ReductiveDevTools.Extension.enhancerOptions( 131 | /* the instance name to be showed on the monitor page */ 132 | ~name="SomeTest", 133 | 134 | /* action creators functions to be available in the Dispatcher. */ 135 | ~actionCreators={ 136 | "increment": () => `Increment(()) |. ReductiveDevTools.Utilities.Serializer.serializeAction, 137 | "decrement": () => `Decrement(()) |. ReductiveDevTools.Utilities.Serializer.serializeAction 138 | }, 139 | 140 | /* if more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once */ 141 | ~latency=500, 142 | 143 | /* maximum allowed actions to be stored in the history tree. */ 144 | ~maxAge=50, 145 | 146 | /* actions types to be hidden / shown in the monitors (while passed to the reducers), If `actionsWhitelist` specified, `actionsBlacklist` is ignored. */ 147 | ~actionsBlacklist=[|"StringAction"|], 148 | 149 | /* actions types to be hidden / shown in the monitors (while passed to the reducers), If `actionsWhitelist` specified, `actionsBlacklist` is ignored. */ 150 | ~actionsWhitelist=[|"CounterAction"|], 151 | 152 | /* if specified as `true`, whenever there's an exception in reducers, the monitors will show the error message, and next actions will not be dispatched. */ 153 | ~shouldCatchErrors=false, 154 | 155 | /* If you want to restrict the extension, specify the features you allow. */ 156 | ~features=ReductiveDevTools.Extension.enhancerFeatures( 157 | ~pause=true, 158 | ~persist=true, 159 | ~export=true, 160 | ~import=Obj.magic("custom"), 161 | ~jump=true, 162 | ~dispatch=true, 163 | ()), 164 | 165 | /* if set to true, will include stack trace for every dispatched action, so you can see it in trace tab */ 166 | ~trace=true, 167 | 168 | /* maximum stack trace frames to be stored (in case trace option was provided as true) */ 169 | ~traceLimit=50 170 | ()) 171 | ``` -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambientlight/reductive-dev-tools/94c6c695bd92d86af8e232707a5157088388be3f/assets/demo.gif -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reductive-dev-tools", 3 | "sources": { 4 | "dir" : "src", 5 | "subdirs" : true 6 | }, 7 | "package-specs": [{ 8 | "module": "commonjs", 9 | "in-source": false 10 | }], 11 | "suffix": ".bs.js", 12 | "namespace": true, 13 | "bs-dependencies": [ 14 | "reason-react", 15 | "reductive" 16 | ], 17 | "refmt": 3, 18 | "bsc-flags": ["-bs-g"] 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reductive-dev-tools", 3 | "version": "3.0.0", 4 | "description": "reductive and reason-react reducer component integration with Redux DevTools", 5 | "main": "lib/js/src/reductiveDevTools.bs.js", 6 | "scripts": { 7 | "build": "./node_modules/.bin/bsb -make-world", 8 | "clean": "./node_modules/.bin/bsb -clean", 9 | "dev": "./node_modules/.bin/bsb -make-world -w" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/ambientlight/reductive-dev-tools.git" 14 | }, 15 | "keywords": [ 16 | "redux", 17 | "dev tools", 18 | "reductive", 19 | "redux", 20 | "reason", 21 | "reasonml", 22 | "bucklescript" 23 | ], 24 | "author": "ambientlight", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/ambientlight/reductive-dev-tools/issues" 28 | }, 29 | "homepage": "https://github.com/ambientlight/reductive-dev-tools#readme", 30 | "devDependencies": { 31 | "bs-platform": "^5.2.1", 32 | "reason-cli": "^3.3.3-macos-1", 33 | "reason-react": "^0.7.0", 34 | "reductive": "^2.0.1", 35 | "redux-devtools-extension": "^2.13.5" 36 | }, 37 | "peerDependencies": { 38 | "reductive": "2.x", 39 | "reason-react": ">= 0.5.1 < 1", 40 | "redux-devtools-extension": ">= 2.13.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/connectors.re: -------------------------------------------------------------------------------- 1 | open Utilities; 2 | 3 | /** 4 | * expose reductive store internals 5 | * reductiveDevTools mutates the store at times 6 | */ 7 | type t('action, 'state) = { 8 | mutable state: 'state, 9 | mutable reducer: ('state, 'action) => 'state, 10 | mutable listeners: list(unit => unit), 11 | customDispatcher: 12 | option((t('action, 'state), 'action => unit, 'action) => unit), 13 | }; 14 | 15 | type partialStore('action, 'state) = { 16 | getState: unit => 'state, 17 | dispatch: 'action => unit 18 | }; 19 | 20 | type customSerializer('a, 'b) = { 21 | serialize: 'a => 'b, 22 | deserialize: 'b => 'a 23 | }; 24 | 25 | let createDummyReduxJsStore = (options, lockCallback: bool => unit, didToggle: unit => unit) => { 26 | let composer = Extension.composeWithDevTools(. options); 27 | 28 | /** 29 | const store = createStore(reducer, preloadedState, composeEnhancers( 30 | applyMiddleware(...middleware) 31 | )); 32 | 33 | the next thing is that applyMiddleware passed inside composedEnhancers 34 | assume no other redux js middleware to apply 35 | */ 36 | let devToolsStoreEnhancer = composer(devToolsEnhancer => devToolsEnhancer); 37 | let dummyReduxJsStoreCreator = (reducer, initial, _) => { 38 | let listeners: array(unit => unit) = [||]; 39 | let state = ref(initial); 40 | 41 | let dispatch = (action: { .. "type": string }) => { 42 | if(action##"type" == "LOCK_CHANGES"){ 43 | lockCallback(action##status) 44 | } else if(action##"type" == "TOGGLE_ACTION"){ 45 | didToggle(); 46 | }; 47 | 48 | let newState = reducer(state^, action); 49 | state := newState; 50 | 51 | listeners 52 | |. Belt.Array.forEach(listener => listener()); 53 | 54 | state^ 55 | }; 56 | 57 | /** 58 | if we don't mimic reduxjs's initial action devtools seem to lag the resulting state by 1 59 | since redux-dev-tools seems to fake the initial @@redux/INIT action 60 | (we will see it in the monitor even if the thing below is not dispatched) 61 | */ 62 | dispatch({ "type": "@@redux/INIT", "status": false }); 63 | 64 | ReduxJsStore.t( 65 | ~dispatch, 66 | ~subscribe=listener => { 67 | Js.Array.push(listener, listeners) |> ignore; 68 | }, 69 | ~getState=() => state^, 70 | ~replaceReducer=_reducer => { 71 | // noop 72 | let self = [%bs.raw "this"]; 73 | self 74 | } 75 | ) 76 | }; 77 | 78 | // let createStore: Types.reduxJsStoreCreator('action, 'state) = [%bs.raw "require('redux').createStore"]; 79 | let createStore: ReduxJsStore.storeCreator('action, 'state) = (reducer, initial, enhancer) => { 80 | switch(enhancer |> Js.toOption){ 81 | | Some(enhancer) => { 82 | (enhancer @@ dummyReduxJsStoreCreator)(reducer, initial, ()) 83 | } 84 | | None => dummyReduxJsStoreCreator(reducer, initial, ()) 85 | } 86 | }; 87 | 88 | (reducer, initial, _enhancer) => createStore(reducer, initial, Js.Nullable.return(devToolsStoreEnhancer)) 89 | }; 90 | 91 | let createReduxJsBridgeMiddleware = ( 92 | ~options: Extension.enhancerOptions('actionCreator), 93 | ~devToolsUpdateActionCreator: ('state) => 'action, 94 | ~actionSerializer:option(customSerializer('action, 'serializedAction))=?, 95 | ~stateSerializer:option(customSerializer('state, 'serializedState))=?, 96 | ~lockCallback:option(bool => unit)=?, 97 | unit 98 | ) => { 99 | let _bridgedReduxJsStore = ref(None); 100 | /** 101 | used to track whether actions have been dispatched 102 | from reductive store or from monitor 103 | 104 | every action dispatched from reductive will increment 105 | while every digested action coming from reduxjs store subscription will decrement back 106 | negative number in below reduxjs subscription will indicate action originate from monitor 107 | so dispatch DevToolStateUpdate action to let clients sync reductive state with monitor's 108 | PLEASE NOTE, reductive reducers need to handle this action themselves 109 | */ 110 | let _outstandingActionsCount = ref(0); 111 | let _extensionLocked = ref(false); 112 | /** 113 | toggle recalculates: reduces over all states after the one that is toggled 114 | if it would be accompanied with @@INIT action we won't need to track if toggle was triggered 115 | as we need to resync the state before dispatching to reductive store all subsequent actions after toggled 116 | */ 117 | let _justToggled = ref(false); 118 | let _didInit = ref(false); 119 | 120 | // TODO: make serialization and below bridging sound with type system 121 | 122 | let actionSerializer = actionSerializer |> Obj.magic |. Belt.Option.getWithDefault({ 123 | serialize: Utilities.Serializer.serializeAction, 124 | deserialize: Utilities.Serializer.deserializeAction 125 | }); 126 | 127 | let stateSerializer = stateSerializer |> Obj.magic |. Belt.Option.getWithDefault({ 128 | serialize: obj => obj, 129 | deserialize: obj => obj 130 | }); 131 | 132 | (store: partialStore('action, 'state)) => { 133 | let reduxJsStore = switch(_bridgedReduxJsStore^){ 134 | | Some(reduxJsStore) => reduxJsStore 135 | | None => { 136 | let bridgedStore = createDummyReduxJsStore( 137 | options, 138 | (locked) => { 139 | _extensionLocked := locked; 140 | (lockCallback |. Belt.Option.getWithDefault(_ => ()))(locked) 141 | }, 142 | () => { _justToggled := true})( 143 | 144 | // reduxjs reducer bridges reductive updated state 145 | (state, action) => { 146 | if(_outstandingActionsCount^ <= 0){ 147 | /** 148 | synch reductive state during @@INIT dispatched from monitor (RESET/IMPORT/REVERT) + TOGGLE_ACTION 149 | no need to resynch @@INIT action on initial mount 150 | */ 151 | if(action##"type" == "@@INIT" || _justToggled^){ 152 | _justToggled := false 153 | 154 | if(_didInit^){ 155 | _outstandingActionsCount := (_outstandingActionsCount^ - 1); 156 | store.dispatch(devToolsUpdateActionCreator(state |> Obj.magic |> stateSerializer.deserialize |> Obj.magic |> Js.Obj.assign(Js.Obj.empty()) |> Obj.magic)); 157 | } else { 158 | _didInit := true 159 | } 160 | }; 161 | 162 | if(action##"type" != "@@INIT"){ 163 | _outstandingActionsCount := (_outstandingActionsCount^ - 1); 164 | store.dispatch(actionSerializer.deserialize(action |> Obj.magic)); 165 | } 166 | }; 167 | 168 | store.getState() 169 | |> stateSerializer.serialize 170 | |> Obj.magic 171 | }, 172 | 173 | store.getState() 174 | |> stateSerializer.serialize 175 | |> Obj.magic, 176 | () 177 | ); 178 | 179 | bridgedStore |. ReduxJsStore.subscribeGet(() => !_extensionLocked^ ? { 180 | _outstandingActionsCount := (_outstandingActionsCount^ - 1); 181 | if(_outstandingActionsCount^ < 0){ 182 | store.dispatch(devToolsUpdateActionCreator(bridgedStore |. ReduxJsStore.getStateGet() |> Obj.magic |> stateSerializer.deserialize |> Obj.magic |> Js.Obj.assign(Js.Obj.empty()) |> Obj.magic)); 183 | }; 184 | } : ()); 185 | 186 | _bridgedReduxJsStore := Some(bridgedStore); 187 | bridgedStore 188 | }}; 189 | 190 | (next, action) => { 191 | // discard actions from reductive when monitor is locked 192 | // still allow actions from monitor though for time-travel 193 | if(_extensionLocked^ && _outstandingActionsCount^ >= 0){ 194 | () 195 | } else { 196 | next(action) 197 | 198 | _outstandingActionsCount := (_outstandingActionsCount^ + 1); 199 | if(_outstandingActionsCount^ > 0){ 200 | // relay the actions to the reduxjs store 201 | let jsAction = actionSerializer.serialize(action); 202 | reduxJsStore |. ReduxJsStore.dispatchGet(jsAction |> Obj.magic); 203 | } 204 | } 205 | } 206 | }; 207 | }; 208 | 209 | let enhancer: ( 210 | ~options: Extension.enhancerOptions('actionCreator), 211 | ~devToolsUpdateActionCreator: ('state) => 'action, 212 | ~actionSerializer: customSerializer('action, 'serializedAction)=?, 213 | ~stateSerializer: customSerializer('state, 'serializedState)=?, 214 | unit 215 | ) => Types.storeEnhancer('action, 'state, 'action, 'state) = (~options, ~devToolsUpdateActionCreator, ~actionSerializer=?, ~stateSerializer=?, ()) => (storeCreator: Types.storeCreator('action, 'state)) => (~reducer, ~preloadedState, ~enhancer=?, ()) => { 216 | let reduxJsBridgeMiddleware = createReduxJsBridgeMiddleware(~options, ~devToolsUpdateActionCreator, ~actionSerializer?, ~stateSerializer?, ()); 217 | 218 | storeCreator( 219 | ~reducer, 220 | ~preloadedState, 221 | ~enhancer=?(Extension.extension == Js.undefined 222 | ? enhancer 223 | : Some(enhancer 224 | |. Belt.Option.mapWithDefault( 225 | store => reduxJsBridgeMiddleware({ 226 | getState: () => Reductive.Store.getState(store), 227 | dispatch: Reductive.Store.dispatch(store) 228 | }), 229 | middleware => (store, next) => reduxJsBridgeMiddleware({ 230 | getState: () => Reductive.Store.getState(store), 231 | dispatch: Reductive.Store.dispatch(store) 232 | }) @@ middleware(store) @@ next 233 | ))), 234 | ()); 235 | } 236 | 237 | let captureNextAction = lastAction => reducer => (state, action) => { 238 | lastAction := Some(action); 239 | reducer(state, action) 240 | }; 241 | 242 | let lockReducer = lock => reducer => (state, action) => { 243 | if(!lock^){ 244 | reducer(state, action) 245 | } else { 246 | state 247 | } 248 | }; 249 | 250 | let useReducer: ( 251 | ~options: Extension.enhancerOptions('actionCreator), 252 | ~devToolsUpdateActionCreator: ('state) => 'action, 253 | ~reducer: ('state, 'action) => 'state, 254 | ~initial: 'state, 255 | ~actionSerializer: customSerializer('action, 'serializedAction)=?, 256 | ~stateSerializer: customSerializer('state, 'serializedState)=?, 257 | unit 258 | ) => ('state, 'action => unit) = (~options, ~devToolsUpdateActionCreator, ~reducer, ~initial, ~actionSerializer=?, ~stateSerializer=?, ()) => { 259 | let lastAction: ref(option('action)) = React.useMemo0(() => ref(None)); 260 | let extensionLocked = React.useMemo0(() => ref(false)); 261 | if(Extension.extension == Js.undefined){ 262 | React.useReducer(reducer, initial); 263 | } else { 264 | let targetReducer = React.useMemo1(() => lockReducer(extensionLocked) @@ captureNextAction(lastAction) @@ reducer, [|reducer|]); 265 | let (state, dispatch) = React.useReducer(targetReducer, initial); 266 | let reduxJsBridgeMiddleware = React.useMemo0( 267 | () => createReduxJsBridgeMiddleware(~options, ~devToolsUpdateActionCreator, ~actionSerializer?, ~stateSerializer?, ~lockCallback=locked => { extensionLocked := locked }, ()) 268 | ); 269 | 270 | let retained = React.useMemo0(() => ref(initial)); 271 | 272 | /** 273 | we have to go this way 274 | rather then having a reducer wrapping in higher-order reducer(like for reductive case above) 275 | that will relay to the extension 276 | since we cannot ensure that clients will define the reducer outside of component scope, 277 | in the opossite case internal reacts updateReducer will be called on each render 278 | which will result in actions dispatched twice to reducer (https://github.com/facebook/react/issues/16295) 279 | */ 280 | retained := state; 281 | React.useEffect1(() => { 282 | let middleware = reduxJsBridgeMiddleware({ 283 | getState: () => retained^, 284 | dispatch 285 | }); 286 | 287 | switch(lastAction^){ 288 | | Some(action) => middleware((_action) => (), action) 289 | | _ => () 290 | }; 291 | 292 | None 293 | }, [|state|]); 294 | 295 | (state, dispatch) 296 | } 297 | }; -------------------------------------------------------------------------------- /src/connectors.rei: -------------------------------------------------------------------------------- 1 | type customSerializer('a, 'b) = { 2 | serialize: 'a => 'b, 3 | deserialize: 'b => 'a 4 | }; 5 | 6 | let enhancer: ( 7 | ~options: Extension.enhancerOptions('actionCreator), 8 | ~devToolsUpdateActionCreator: ('state) => 'action, 9 | ~actionSerializer: customSerializer('action, 'serializedAction)=?, 10 | ~stateSerializer: customSerializer('state, 'serializedState)=?, 11 | unit) => Types.storeEnhancer('action, 'state, 'action, 'state); 12 | 13 | let useReducer: ( 14 | ~options: Extension.enhancerOptions('actionCreator), 15 | ~devToolsUpdateActionCreator: ('state) => 'action, 16 | ~reducer: ('state, 'action) => 'state, 17 | ~initial: 'state, 18 | ~actionSerializer: customSerializer('action, 'serializedAction)=?, 19 | ~stateSerializer: customSerializer('state, 'serializedState)=?, 20 | unit 21 | ) => ('state, 'action => unit); -------------------------------------------------------------------------------- /src/extension.re: -------------------------------------------------------------------------------- 1 | [@bs.deriving abstract] 2 | type serializeOptions = { 3 | [@bs.optional] date: bool, 4 | [@bs.optional] regex: bool, 5 | [@bs.optional] undefined: bool, 6 | [@bs.optional] error: bool, 7 | [@bs.optional] symbol: bool, 8 | [@bs.optional] map: bool, 9 | [@bs.optional] set: bool, 10 | [@bs.optional][@bs.as "function"] function_: bool, 11 | 12 | [@bs.optional] replacer: (string, Js.t({.})) => Js.t({.}) 13 | }; 14 | 15 | [@bs.deriving abstract] 16 | type enhancerFeatures = { 17 | /** 18 | * start/pause recording of dispatched actions 19 | */ 20 | [@bs.optional] pause: bool, 21 | /** 22 | * lock/unlock dispatching actions and side effects 23 | */ 24 | [@bs.optional] lock: bool, 25 | /** 26 | * persist states on page reloading 27 | */ 28 | [@bs.optional] persist: bool, 29 | 30 | /** https://github.com/BuckleScript/bucklescript/issues/2934 */ 31 | /* [@bs.optional][@bs.unwrap] export: [ 32 | | `Bool(bool) 33 | | `String(string) 34 | ], */ 35 | 36 | /** 37 | * export history of actions in a file 38 | * bool or "custom" 39 | * since we cannot use polymorhic variant and [@bs.unwrap] here, 40 | * pass "custom" as Obj.magic("custom") if needed 41 | */ 42 | [@bs.optional] export: bool, 43 | 44 | /** https://github.com/BuckleScript/bucklescript/issues/2934 */ 45 | /* [@bs.optional][@bs.unwrap] import: [ 46 | | `Bool(bool) 47 | | `String(string) 48 | ], */ 49 | 50 | /** 51 | * import history of actions from a file 52 | * bool or "custom" 53 | * since we cannot use polymorhic variant and [@bs.unwrap] here, 54 | * pass "custom" as Obj.magic("custom") if needed 55 | */ 56 | [@bs.optional] import: bool, 57 | /** 58 | * jump back and forth (time travelling) 59 | */ 60 | [@bs.optional] jump: bool, 61 | /** 62 | * skip (cancel) actions 63 | */ 64 | [@bs.optional] skip: bool, 65 | /** 66 | * drag and drop actions in the history list 67 | */ 68 | [@bs.optional] reorder: bool, 69 | /** 70 | * dispatch custom actions or action creators 71 | */ 72 | [@bs.optional] dispatch: bool, 73 | 74 | /** 75 | * generate tests for the selected actions 76 | * 77 | * NOT AVAILABLE FOR NON-REDUX 78 | */ 79 | [@bs.optional] test: bool 80 | }; 81 | 82 | type extension = Js.t({.}); 83 | type connection = Js.t({.}); 84 | 85 | [@bs.deriving abstract] 86 | type enhancerOptions('actionCreator) = { 87 | /** 88 | * the instance name to be showed on the monitor page. Default value is `document.title`. 89 | */ 90 | name: string, 91 | 92 | /** 93 | * action creators functions to be available in the Dispatcher. 94 | */ 95 | [@bs.optional] actionCreators: Js.t({..} as 'actionCreator), 96 | 97 | /** 98 | * if more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. 99 | * It is the joint between performance and speed. When set to `0`, all actions will be sent instantly. 100 | * Set it to a higher value when experiencing perf issues (also `maxAge` to a lower value). 101 | * 102 | * @default 500 ms. 103 | */ 104 | [@bs.optional] latency: int, 105 | 106 | /** 107 | * (> 1) - maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. It's critical for performance. 108 | * 109 | * @default 50 110 | */ 111 | [@bs.optional] maxAge: int, 112 | 113 | /** 114 | * - `undefined` - will use regular `JSON.stringify` to send data (it's the fast mode). 115 | * - `false` - will handle also circular references. 116 | * - `true` - will handle also date, regex, undefined, error objects, symbols, maps, sets and functions. 117 | * - object, which contains `date`, `regex`, `undefined`, `error`, `symbol`, `map`, `set` and `function` keys. 118 | * For each of them you can indicate if to include (by setting as `true`). 119 | * For `function` key you can also specify a custom function which handles serialization. 120 | * See [`jsan`](https://github.com/kolodny/jsan) for more details. 121 | */ 122 | [@bs.optional] serialize: serializeOptions, 123 | 124 | /** 125 | * function which takes `action` object and id number as arguments, and should return `action` object back. 126 | */ 127 | /* [@bs.optional] actionSanitizer: (. 'action) => 'action, */ 128 | 129 | /** 130 | * function which takes `state` object and index as arguments, and should return `state` object back. 131 | */ 132 | /* [@bs.optional] stateSanitizer: (. 'state) => 'state, */ 133 | 134 | /** 135 | * *string or array of strings as regex* - actions types to be hidden / shown in the monitors (while passed to the reducers). 136 | * If `actionsWhitelist` specified, `actionsBlacklist` is ignored. 137 | */ 138 | [@bs.optional] actionsBlacklist: array(string), 139 | 140 | /** 141 | * *string or array of strings as regex* - actions types to be hidden / shown in the monitors (while passed to the reducers). 142 | * If `actionsWhitelist` specified, `actionsBlacklist` is ignored. 143 | */ 144 | [@bs.optional] actionsWhitelist: array(string), 145 | 146 | /** 147 | * called for every action before sending, takes `state` and `action` object, and returns `true` in case it allows sending the current data to the monitor. 148 | * Use it as a more advanced version of `actionsBlacklist`/`actionsWhitelist` parameters. 149 | */ 150 | /* [@bs.optional] predicate: (. 'state, 'action) => bool, */ 151 | 152 | /** 153 | * if specified as `false`, it will not record the changes till clicking on `Start recording` button. 154 | * Available only for Redux enhancer, for others use `autoPause`. 155 | * 156 | * @default true 157 | */ 158 | [@bs.optional] shouldRecordChanges: bool, 159 | 160 | /** 161 | * if specified, whenever clicking on `Pause recording` button and there are actions in the history log, will add this action type. 162 | * If not specified, will commit when paused. Available only for Redux enhancer. 163 | */ 164 | [@bs.optional] pauseActionType: bool, 165 | 166 | /** 167 | * auto pauses when the extension’s window is not opened, and so has zero impact on your app when not in use. 168 | * Not available for Redux enhancer (as it already does it but storing the data to be sent). 169 | * 170 | * @default false 171 | */ 172 | [@bs.optional] autoPause: bool, 173 | 174 | /** 175 | * if specified as `true`, it will not allow any non-monitor actions to be dispatched till clicking on `Unlock changes` button. 176 | * Available only for Redux enhancer. 177 | * 178 | * @default false 179 | */ 180 | [@bs.optional] shouldStartLocked: bool, 181 | 182 | /** 183 | * if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Available only for Redux enhancer. 184 | * 185 | * @default true 186 | */ 187 | [@bs.optional] shouldHotReload: bool, 188 | 189 | /** 190 | * if specified as `true`, whenever there's an exception in reducers, the monitors will show the error message, and next actions will not be dispatched. 191 | * 192 | * @default false 193 | */ 194 | [@bs.optional] shouldCatchErrors: bool, 195 | 196 | /** 197 | * If you want to restrict the extension, specify the features you allow. 198 | * If not specified, all of the features are enabled. When set as an object, only those included as `true` will be allowed. 199 | * Note that except `true`/`false`, `import` and `export` can be set as `custom` (which is by default for Redux enhancer), meaning that the importing/exporting occurs on the client side. 200 | * Otherwise, you'll get/set the data right from the monitor part. 201 | */ 202 | [@bs.optional] features: enhancerFeatures, 203 | 204 | /** 205 | * if set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code 206 | */ 207 | [@bs.optional] trace: bool, 208 | 209 | /** 210 | * maximum stack trace frames to be stored (in case trace option was provided as true). 211 | * By default it's 10. Note that, because extension's calls are excluded, the resulted frames could be 1 less. 212 | * If trace option is a function, traceLimit will have no effect, as it's supposed to be handled there. 213 | */ 214 | [@bs.optional] traceLimit: int 215 | }; 216 | 217 | [@bs.val] [@bs.scope "window"] 218 | external extension: Js.Undefined.t(extension) = "__REDUX_DEVTOOLS_EXTENSION__"; 219 | 220 | [@bs.val] [@bs.scope "window"] 221 | external devToolsExtensionLocked: bool = "__REDUX_DEVTOOLS_EXTENSION_LOCKED__"; 222 | 223 | [@bs.module "redux-devtools-extension"] 224 | external _devToolsEnhancer: extension = "devToolsEnhancer"; 225 | 226 | type composer('action, 'state) = ReduxJsStore.storeEnhancer('action, 'state) => ReduxJsStore.storeEnhancer('action, 'state); 227 | 228 | [@bs.module "redux-devtools-extension"] 229 | external composeWithDevTools: (. enhancerOptions('actionCreator)) => composer('action, 'state) = "composeWithDevTools"; 230 | 231 | let devToolsEnhancer = _devToolsEnhancer; 232 | 233 | [@bs.send] external _connect: (~extension: extension, ~options: enhancerOptions('actionCreator)) => connection = "connect"; 234 | [@bs.send] external _disconnect: (~extension: extension) => unit = "disconnect"; 235 | [@bs.send] external _send: (~extension: extension, ~action: 'action, ~state: 'state, ~options: enhancerOptions('actionCreator)=?, ~instanceId: string=?, unit) => unit = "send"; 236 | [@bs.send] external _listen: (~extension: extension, ~onMessage: 'action => unit, ~instanceId: string) => unit = "listen"; 237 | [@bs.send] external _open: (~extension: extension, ~position: string=?, unit) => unit = "open"; 238 | [@bs.send] external _notifyErrors: (~onError: (. Js.t({..}) ) => unit=?, unit) => unit = "notifyErrors"; 239 | 240 | let connect = (~extension: extension, ~options: enhancerOptions('actionCreator)) => _connect(~extension, ~options); 241 | 242 | /** 243 | * Remove extensions listener and disconnect extensions background script connection. 244 | * Usually just unsubscribing the listiner inside the connect is enough. 245 | */ 246 | let disconnect = (~extension: extension) => _disconnect(~extension); 247 | 248 | /** 249 | * Send a new action and state manually to be shown on the monitor. 250 | * It's recommended to use connect, unless you want to hook into an already created instance. 251 | */ 252 | let send = (~extension: extension, ~action: 'action, ~state: 'state, ~options=?, ~instanceId=?, _:unit) => _send(~extension, ~action, ~state, ~options?, ~instanceId?, ()); 253 | 254 | /** 255 | * Listen for messages dispatched for specific instanceId. 256 | * For most of cases it's better to use subcribe inside the connect. 257 | */ 258 | let listen = (~extension: extension, ~onMessage: 'action => unit, ~instanceId: string) => _listen(~extension, ~onMessage, ~instanceId); 259 | 260 | /** 261 | * Open the extension's window. 262 | * This should be conditional (usually you don't need to open extension's window automatically). 263 | */ 264 | let open_ = (~extension: extension, ~position=?, _:unit) => _open(~extension, ~position?, ()); 265 | 266 | /** 267 | * When called, the extension will listen for uncaught exceptions on the page, and, if any, will show native notifications. 268 | * Optionally, you can provide a function to be called when and exception occurs. 269 | */ 270 | let notifyErrors = (~onError=?, _: unit) => _notifyErrors(~onError?, ()); 271 | 272 | 273 | [@bs.send] external _subscribe: (~connection: connection, ~listener: 'action => unit) => (. unit) => unit = "subscribe"; 274 | [@bs.send] external _unsubscribe: (~connection: connection) => unit = "unsubscribe"; 275 | [@bs.send] external _send: (~connection: connection, ~action: Js.Null.t('action), ~state: 'state) => unit = "send"; 276 | [@bs.send] external _init: (~connection: connection, ~state: 'state) => unit = "init"; 277 | [@bs.send] external _error: (~connection: connection, ~message: string) => unit = "error"; 278 | 279 | /** 280 | * adds a change listener. It will be called any time an action is dispatched form the monitor. 281 | * Returns a function to unsubscribe the current listener. 282 | */ 283 | let subscribe = (~connection: connection, ~listener: 'action => unit) => _subscribe(~connection, ~listener); 284 | 285 | /** 286 | * unsubscribes all listeners. 287 | */ 288 | let unsubscribe = (~connection: connection) => _unsubscribe(~connection); 289 | 290 | /** 291 | * sends a new action and state manually to be shown on the monitor. 292 | * If action is null then we suppose we send liftedState. 293 | */ 294 | let send = (~connection: connection, ~action: Js.Null.t('action), ~state: 'state) => _send(~connection, ~action, ~state); 295 | /** 296 | * sends the initial state to the monitor 297 | */ 298 | let init = (~connection: connection, ~state: 'state) => _init(~connection, ~state); 299 | /** 300 | * sends the error message to be shown in the extension's monitor. 301 | */ 302 | let error = (~connection: connection, ~message: string) => _error(~connection, ~message); 303 | 304 | 305 | module Monitor { 306 | module LiftedStateAction { 307 | [@bs.deriving abstract] 308 | type t('action) = { 309 | action: 'action, 310 | timestamp: int, 311 | [@bs.as "type"] type_: string 312 | }; 313 | }; 314 | 315 | module ComputedState { 316 | [@bs.deriving abstract] 317 | type t('state) = { 318 | mutable state: 'state 319 | }; 320 | }; 321 | 322 | module LiftedState { 323 | [@bs.deriving abstract] 324 | type t('state, 'action) = { 325 | actionsById: Js.Dict.t(LiftedStateAction.t('action)), 326 | computedStates: array(ComputedState.t('state)), 327 | currentStateIndex: int, 328 | nextActionId: int, 329 | skippedActionIds: array(int), 330 | stagedActionIds: array(int) 331 | }; 332 | }; 333 | 334 | module ActionPayload { 335 | [@bs.deriving abstract] 336 | type t('state, 'action) = { 337 | [@bs.as "type"] type_: string, 338 | [@bs.optional] timestamp: int, 339 | [@bs.optional] id: int, 340 | [@bs.optional] actionId: int, 341 | [@bs.optional] index: int, 342 | [@bs.optional] nextLiftedState: LiftedState.t('state, 'action) 343 | }; 344 | }; 345 | 346 | module Action { 347 | [@bs.deriving abstract] 348 | type t('state, 'action) = { 349 | [@bs.as "type"] type_: string, 350 | [@bs.optional] payload: ActionPayload.t('state, 'action), 351 | [@bs.optional] state: string 352 | }; 353 | }; 354 | }; -------------------------------------------------------------------------------- /src/reduxJsStore.re: -------------------------------------------------------------------------------- 1 | type reduxJsListener = unit => unit; 2 | 3 | [@bs.deriving abstract] 4 | type t('state, 'action) = { 5 | dispatch: 'action => unit, 6 | subscribe: reduxJsListener => unit, 7 | getState: unit => 'state, 8 | replaceReducer: Types.reducer('action, 'state) => t('state, 'action) 9 | }; 10 | 11 | type middleware('action, 'state) = 12 | (t('action, 'state), 'action => unit, 'action) => unit; 13 | 14 | type _storeCreator('action, 'state) = ( 15 | Types.reducer('action, 'state), 16 | 'state, 17 | unit 18 | ) => t('state, 'action); 19 | 20 | type storeEnhancer('action, 'state) = _storeCreator('action, 'state) => _storeCreator('action, 'state); 21 | 22 | type storeCreator('action, 'state) = ( 23 | Types.reducer('action, 'state), 24 | 'state, 25 | Js.Nullable.t(storeEnhancer('action, 'state)) 26 | ) => t('state, 'action) -------------------------------------------------------------------------------- /src/types.re: -------------------------------------------------------------------------------- 1 | // TODO: make the next type definitions available at reductive side 2 | 3 | type store('action, 'state) = Reductive.Store.t('action, 'state); 4 | type reducer('action, 'state) = ('state, 'action) => 'state; 5 | 6 | type middleware('action, 'state) = 7 | (store('action, 'state), 'action => unit, 'action) => unit; 8 | 9 | type storeCreator('action, 'state) = 10 | ( 11 | ~reducer: reducer('action, 'state), 12 | ~preloadedState: 'state, 13 | ~enhancer: middleware('action, 'state)=?, 14 | unit 15 | ) => 16 | store('action, 'state); 17 | 18 | type storeEnhancer('action, 'state, 'enhancedAction, 'enhancedState) = 19 | storeCreator('action, 'state) => storeCreator('enhancedAction, 'enhancedState); -------------------------------------------------------------------------------- /src/utilities.re: -------------------------------------------------------------------------------- 1 | module Symbol { 2 | type t = Js.t({.}); 3 | 4 | [@bs.val][@bs.scope "Object"] 5 | external _defineSymbol: ('a, t, 'b) => unit = "defineProperty"; 6 | 7 | let create: string => t = [%raw {| 8 | function(key){ return Symbol(key) } 9 | |}]; 10 | 11 | let getValue = (obj, symbol: t) => Js.Dict.unsafeGet(Obj.magic(obj), Obj.magic(symbol)); 12 | let setValue = (obj, symbol, value) => _defineSymbol(obj, symbol, [%bs.obj { 13 | value: value, 14 | writable: false 15 | }]); 16 | 17 | [@bs.send] 18 | external toString: t => string = "toString"; 19 | }; 20 | 21 | module Object { 22 | [@bs.val][@bs.scope "Object"] 23 | external _getOwnPropertySymbols: 'a => array(Symbol.t) = "getOwnPropertySymbols"; 24 | 25 | let getOwnPropertySymbols = obj => switch(Js.Types.classify(obj)){ 26 | | JSObject(obj) => _getOwnPropertySymbols(obj) 27 | | _ => [||] 28 | }; 29 | }; 30 | 31 | module Serializer { 32 | module DebugSymbol { 33 | [@bs.deriving jsConverter] 34 | type t = [ 35 | // legacy pre-bs8 symbols 36 | // | [@bs.as "Symbol(BsVariant)"] `BsVariant 37 | // | [@bs.as "Symbol(BsPolyVar)"] `BsPolyVar 38 | // | [@bs.as "Symbol(BsRecord)"] `BsRecord 39 | // | [@bs.as "Symbol(ReductiveDevToolsBsLabeledVariant)"] `DevToolsBsLabeledVariant 40 | | [@bs.as "Symbol(name)"] `Name 41 | ]; 42 | 43 | let ofReasonAction = action => { 44 | let symbols = Object.getOwnPropertySymbols(action); 45 | let extractedSymbols = symbols 46 | |> Array.mapi((idx, symbol) => (idx, symbol |. Symbol.toString)); 47 | 48 | /** 49 | * make sure Bs* symbols always appearing before ReductiveDevTool ones 50 | * (logic will pick first symbol available) 51 | * assumption: Bs* symbols are exclusive, but they can combine with ReductiveDevTool* ones 52 | * which provide additional metadata(user specified) 53 | */ 54 | Array.sort(((_, lhs), (_, rhs)) => compare(lhs, rhs), extractedSymbols); 55 | extractedSymbols 56 | |> Array.map(((idx, symbol)) => (idx, tFromJs(symbol))) 57 | |. Belt.Array.keep(((_, symbol)) => Belt.Option.isSome(symbol)) 58 | |> Array.map( ((idx, symbol)) => (idx, Belt.Option.getExn(symbol)) ); 59 | } 60 | 61 | let symbolValue = (action, debugSymbol: t) => 62 | ofReasonAction(action) 63 | |. Belt.Array.keep(((_, symbol)) => symbol == debugSymbol) 64 | |. Belt.Array.get(0) 65 | |. Belt.Option.map(((idx, _)) => { 66 | let symbol = Array.get(Object.getOwnPropertySymbols(action), idx); 67 | Symbol.getValue(action, symbol) 68 | }) 69 | }; 70 | 71 | module Action { 72 | 73 | type t('a) = Js.t({ 74 | .. 75 | /* 76 | * denotes the action name displayed in the monitor 77 | * for an actual action 'type', please check Internals.t._type 78 | */ 79 | _type: string 80 | }) as 'a; 81 | 82 | 83 | let fromReasonAction: 'action => t({. "_type": string}) = action => { 84 | DebugSymbol.symbolValue(action, `Name) 85 | |. Belt.Option.map(actionName => { 86 | let base = [%bs.obj {_type: actionName }]; 87 | Js.Obj.assign(base, Obj.magic(action)) 88 | }) 89 | |. Belt.Option.getWithDefault(Obj.magic(action)) 90 | } 91 | 92 | let toReasonAction = (action: t('s)) => { 93 | // not errasing a type field here,, if needed, use a customSerializer 94 | Obj.magic(action) 95 | } 96 | }; 97 | 98 | let serializeAction = action => Action.fromReasonAction(action); 99 | let deserializeAction = action => Action.toReasonAction(action); 100 | }; -------------------------------------------------------------------------------- /src/utilities.rei: -------------------------------------------------------------------------------- 1 | module Symbol { 2 | type t = Js.t({.}); 3 | 4 | let getValue: ('b, t) => 'a; 5 | let setValue: ('a, t, 'b) => unit; 6 | let toString: t => string; 7 | }; 8 | 9 | module Serializer { 10 | module DebugSymbol { 11 | type t = [ 12 | // legacy pre-bs8 symbols 13 | // | [@bs.as "Symbol(BsVariant)"] `BsVariant 14 | // | [@bs.as "Symbol(BsPolyVar)"] `BsPolyVar 15 | // | [@bs.as "Symbol(BsRecord)"] `BsRecord 16 | // | [@bs.as "Symbol(ReductiveDevToolsBsLabeledVariant)"] `DevToolsBsLabeledVariant 17 | | [@bs.as "Symbol(name)"] `Name 18 | ]; 19 | 20 | let ofReasonAction: 'a => array((int, t)); 21 | let symbolValue: ('b, t) => option('a) 22 | } 23 | 24 | module Action { 25 | 26 | type t('a) = Js.t({ 27 | .. 28 | /* 29 | * denotes the action name displayed in the monitor 30 | * for an actual action 'type', please check Internals.t._type 31 | */ 32 | _type: string 33 | }) as 'a; 34 | }; 35 | 36 | let serializeAction: 'a => Action.t({. "_type": string}); 37 | let deserializeAction: Action.t({. "_type": string}) => 'a; 38 | }; --------------------------------------------------------------------------------