Repository: pmndrs/zustand (ts)

Issue:
RN Expo [zustand devtools middleware] Please install/enable Redux devtools extension
I use the latest version of zustand 3.7.1
<img width="1179" alt="Screenshot 2022-03-05 at 11 31 43" src="https://user-images.githubusercontent.com/33237937/156879504-41b498f6-8be8-4245-ab9e-0914c3cce316.png">


Zustand DevTools log in production code
My understanding is that the devtools only load in development and when `NODE_ENV === 'production'` they are not included. That being said, I'm looking at my output JS and I'm seeing the `console.warn` still showing in there even though its around a `__DEV__` condition, which I assume is looking at `NODE_ENV === 'development'` from what I can gather looking through the source. Why would this log still be showing (`[zustand devtools middleware] Please install/enable Redux devtools extension`)? Here is a snipped of my production code where you can see the log(s) are still present:

```js
u=void 0===t?{name:void 0,anonymousActionType:void 0}:"string"==typeof t?{name:t}:t;let s;try{s=window.__REDUX_DEVTOOLS_EXTENSION__||window.top.__REDUX_DEVTOOLS_EXTENSION__}catch{}if(!s)return"undefined"!=typeof window&&console.warn("[zustand devtools middleware] Please install/enable Redux devtools extension"),e(n,r,i);let c=Object.create(s.connect(u)),l=!1;Object.defineProperty(i,"devtools",{get:()=>(l||(console.warn("[zustand devtools middleware] `devtools` property on the store is deprecated it will be removed in the next major.\nYou shouldn't interact with the extension directly. But in case you still want to you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly"),l=!0),c),set:e=>{l||(console.warn("[zustand devtools middleware] `api.devtools` is deprecated, it will be removed in the next major.\nYou shouldn't interact with the extension directly. But in case you still want to you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly"),l=!0),c=e}});
```

This is my hook:
```js
export const useUserStore = create(
    devtools(set => ({
        ...storeStuff
    }))
);
```
(middleware/devtools) Remove "Please install/enable Redux devtools" warning
That warning is to let developers know when they use `devtools` middleware, but they forgot to use DevTools Extension. It might help some developers, which is a benefit. However, as reported in #852 (and https://github.com/pmndrs/valtio/pull/378), this is troublesome as it's not 100% accurate. It feels like the trouble is more than the benefit.

As the official redux doesn't seem to have such a warning, let's remove the warning altogether.

(We'll do it in zustand first, and make jotai and valtio to follow it.)

Pull request description:
fix(middleware/devtools): add `enabled` option and show devtools warning only if enabled
Resolves #878 
Closes #852 
Also close #874

This PR adds `enabled` option and, if `enabled=true` shows the warning  and then return `fn` immediately! This resolved the concern described in #878.

Patch:
diff --git a/package.json b/package.json
index d1a0f8e..b2dcf59 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,12 @@
       "import": "./esm/middleware.mjs",
       "default": "./middleware.js"
     },
+    "./middleware/immer": {
+      "types": "./middleware/immer.d.ts",
+      "module": "./esm/middleware/immer.js",
+      "import": "./esm/middleware/immer.mjs",
+      "default": "./middleware/immer.js"
+    },
     "./shallow": {
       "types": "./shallow.d.ts",
       "module": "./esm/shallow.js",
@@ -48,6 +54,7 @@
     "build:base": "rollup -c",
     "build:vanilla": "rollup -c --config-vanilla",
     "build:middleware": "rollup -c --config-middleware",
+    "build:middleware:immer": "rollup -c --config-middleware_immer",
     "build:shallow": "rollup -c --config-shallow",
     "build:context": "rollup -c --config-context",
     "postbuild": "yarn copy",
@@ -58,7 +65,7 @@
     "test:ci": "jest",
     "test:dev": "jest --watch --no-coverage",
     "test:coverage:watch": "jest --watch",
-    "copy": "shx cp -r dist/src/* dist/esm && shx mv dist/src/* dist && shx rm -rf dist/{src,tests} && shx cp package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\""
+    "copy": "shx cp -r dist/src/* dist/esm && shx cp -r dist/src/* dist && shx rm -rf dist/src && shx rm -rf dist/{src,tests} && shx cp package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.prettier=undefined; this.jest=undefined; this['lint-staged']=undefined;\""
   },
   "engines": {
     "node": ">=12.7.0"
@@ -184,9 +191,13 @@
     "date-fns": "2.27.0"
   },
   "peerDependencies": {
+    "immer": ">=9.0",
     "react": ">=16.8"
   },
   "peerDependenciesMeta": {
+    "immer": {
+      "optional": true
+    },
     "react": {
       "optional": true
     }
diff --git a/readme.md b/readme.md
index 7c8cf4c..b628c3e 100644
--- a/readme.md
+++ b/readme.md
@@ -258,28 +258,16 @@ You can functionally compose your store any way you like.
 
 ```jsx
 // Log every time state is changed
-const log = config => (set, get, api) => config(args => {
+const log = (config) => (set, get, api) => config((...args) => {
   console.log("  applying", args)
-  set(args)
+  set(...args)
   console.log("  new state", get())
 }, get, api)
 
-// Turn the set method into an immer proxy
-const immer = config => (set, get, api) => config((partial, replace) => {
-  const nextState = typeof partial === 'function'
-      ? produce(partial)
-      : partial
-  return set(nextState, replace)
-}, get, api)
-
-const useStore = create(
-  log(
-    immer((set) => ({
-      bees: false,
-      setBees: (input) => set((state) => void (state.bees = input)),
-    })),
-  ),
-)
+const useStore = create(log((set) => ({
+  bees: false,
+  setBees: (input) => set({ bees: input }),
+})))
 ```
 
 ## Persist middleware
@@ -290,7 +278,7 @@ You can persist your store's data using any kind of storage.
 import create from "zustand"
 import { persist } from "zustand/middleware"
 
-export const useStore = create(persist(
+const useStore = create(persist(
   (set, get) => ({
     fishes: 0,
     addAFish: () => set({ fishes: get().fishes + 1 })
@@ -302,6 +290,20 @@ export const useStore = create(persist(
 ))
 ```
 
+## Immer middleware
+
+Immer is available as middleware too.
+
+```jsx
+import create from "zustand"
+import { immer } from "zustand/middleware/immer"
+
+const useStore = create(immer((set) => ({
+  bees: 0,
+  addBees: (by) => set((state) => { state.bees += by }),
+})))
+```
+
 [See the full documentation for this middleware.](https://github.com/pmndrs/zustand/wiki/Persisting-the-store's-data)
 
 ## Can't live without redux-like reducers and action types?
diff --git a/src/middleware.ts b/src/middleware.ts
index c1787ba..9064282 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -3,4 +3,3 @@ export * from './middleware/devtools'
 export * from './middleware/subscribeWithSelector'
 export * from './middleware/combine'
 export * from './middleware/persist'
-export * from './middleware/immer'
diff --git a/src/middleware/devtools.ts b/src/middleware/devtools.ts
index f19f93f..144458a 100644
--- a/src/middleware/devtools.ts
+++ b/src/middleware/devtools.ts
@@ -57,6 +57,7 @@ type StoreSetStateWithAction<S> = S extends {
   : never
 
 interface DevtoolsOptions {
+  enabled?: boolean
   name?: string
   anonymousActionType?: string
   serialize?:
@@ -113,15 +114,17 @@ const devtoolsImpl: DevtoolsImpl = (fn, options) => (set, get, api) => {
       ? { name: options }
       : options
 
-  let extensionConnector
+  const { enabled } = devtoolsOptions as { enabled?: boolean }
+  let extensionConnector: typeof window['__REDUX_DEVTOOLS_EXTENSION__'] | false
   try {
-    extensionConnector = window.__REDUX_DEVTOOLS_EXTENSION__
+    extensionConnector =
+      (enabled ?? __DEV__) && window.__REDUX_DEVTOOLS_EXTENSION__
   } catch {
     // ignored
   }
 
   if (!extensionConnector) {
-    if (__DEV__ && typeof window !== 'undefined') {
+    if (__DEV__ && enabled) {
       console.warn(
         '[zustand devtools middleware] Please install/enable Redux devtools extension'
       )
diff --git a/src/middleware/immer.ts b/src/middleware/immer.ts
index 7c302ca..933b004 100644
--- a/src/middleware/immer.ts
+++ b/src/middleware/immer.ts
@@ -10,7 +10,7 @@ type Immer = <
   initializer: StateCreator<T, [...Mps, ['zustand/immer', never]], Mcs>
 ) => StateCreator<T, Mps, [['zustand/immer', never], ...Mcs]>
 
-declare module 'zustand' {
+declare module '../vanilla' {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   interface StoreMutators<S, A> {
     ['zustand/immer']: WithImmer<S>


Test patch:
diff --git a/tests/devtools.test.tsx b/tests/devtools.test.tsx
index 8805553..f5ffb5b 100644
--- a/tests/devtools.test.tsx
+++ b/tests/devtools.test.tsx
@@ -26,7 +26,7 @@ beforeEach(() => {
 })
 
 it('connects to the extension by passing the options and initializes', () => {
-  const options = { name: 'test', foo: 'bar' }
+  const options = { name: 'test', foo: 'bar', enabled: true }
   const initialState = { count: 0 }
   create(devtools(() => initialState, options))
 
@@ -35,51 +35,57 @@ it('connects to the extension by passing the options and initializes', () => {
 })
 
 describe('If there is no extension installed...', () => {
+  let savedConsoleWarn: any
   let savedDEV: boolean
-  beforeAll(() => {
+  beforeEach(() => {
+    savedConsoleWarn = console.warn
+    console.warn = jest.fn()
     savedDEV = __DEV__
     ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined
   })
-  afterAll(() => {
+  afterEach(() => {
+    console.warn = savedConsoleWarn
     __DEV__ = savedDEV
     ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
   })
 
   it('does not throw', () => {
-    __DEV__ = false
     expect(() => {
       create(devtools(() => ({ count: 0 })))
     }).not.toThrow()
   })
 
-  it('[DEV-ONLY] warns in dev env', () => {
-    __DEV__ = true
-    const originalConsoleWarn = console.warn
-    console.warn = jest.fn()
-
+  it('does not warn if not enabled', () => {
     create(devtools(() => ({ count: 0 })))
-    expect(console.warn).toHaveBeenLastCalledWith(
-      '[zustand devtools middleware] Please install/enable Redux devtools extension'
-    )
+    expect(console.warn).not.toBeCalled()
+  })
 
-    console.warn = originalConsoleWarn
+  it('[DEV-ONLY] warns if enabled in dev mode', () => {
+    __DEV__ = true
+    create(devtools(() => ({ count: 0 }), { enabled: true }))
+    expect(console.warn).toBeCalled()
   })
 
   it('[PRD-ONLY] does not warn if not in dev env', () => {
     __DEV__ = false
-    const consoleWarn = jest.spyOn(console, 'warn')
-
     create(devtools(() => ({ count: 0 })))
-    expect(consoleWarn).not.toBeCalled()
+    expect(console.warn).not.toBeCalled()
+  })
 
-    consoleWarn.mockRestore()
+  it('[PRD-ONLY] does not warn if not in dev env even if enabled', () => {
+    __DEV__ = false
+    create(devtools(() => ({ count: 0 }), { enabled: true }))
+    expect(console.warn).not.toBeCalled()
   })
 })
 
 describe('When state changes...', () => {
   it("sends { type: setStateName || 'anonymous` } as the action with current state", () => {
     const api = create(
-      devtools(() => ({ count: 0, foo: 'bar' }), { name: 'testOptionsName' })
+      devtools(() => ({ count: 0, foo: 'bar' }), {
+        name: 'testOptionsName',
+        enabled: true,
+      })
     )
     api.setState({ count: 10 }, false, 'testSetStateName')
     expect(extension.send).toHaveBeenLastCalledWith(
@@ -98,7 +104,7 @@ describe('when it receives an message of type...', () => {
   describe('ACTION...', () => {
     it('does nothing', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       const setState = jest.spyOn(api, 'setState')
 
       ;(extensionSubscriber as (message: any) => void)({
@@ -112,7 +118,7 @@ describe('when it receives an message of type...', () => {
 
     it('unless action type is __setState', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
 
       ;(extensionSubscriber as (message: any) => void)({
         type: 'ACTION',
@@ -124,7 +130,7 @@ describe('when it receives an message of type...', () => {
 
     it('does nothing even if there is `api.dispatch`', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       ;(api as any).dispatch = jest.fn()
       const setState = jest.spyOn(api, 'setState')
 
@@ -140,7 +146,7 @@ describe('when it receives an message of type...', () => {
 
     it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       ;(api as any).dispatch = jest.fn()
       ;(api as any).dispatchFromDevtools = true
       const setState = jest.spyOn(api, 'setState')
@@ -159,7 +165,7 @@ describe('when it receives an message of type...', () => {
 
     it('does not throw for unsupported payload', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       ;(api as any).dispatch = jest.fn()
       ;(api as any).dispatchFromDevtools = true
       const setState = jest.spyOn(api, 'setState')
@@ -206,7 +212,7 @@ describe('when it receives an message of type...', () => {
   describe('DISPATCH and payload of type...', () => {
     it('RESET, it inits with initial state', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       api.setState({ count: 1 })
 
       extension.send.mockClear()
@@ -222,7 +228,7 @@ describe('when it receives an message of type...', () => {
 
     it('COMMIT, it inits with current state', () => {
       const initialState = { count: 0 }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       api.setState({ count: 2 })
       const currentState = api.getState()
 
@@ -239,7 +245,7 @@ describe('when it receives an message of type...', () => {
     describe('ROLLBACK...', () => {
       it('it updates state without recording and inits with `message.state`', () => {
         const initialState = { count: 0, increment: () => {} }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const newState = { foo: 'bar' }
 
         extension.send.mockClear()
@@ -260,7 +266,7 @@ describe('when it receives an message of type...', () => {
       it('does not throw for unparsable `message.state`', () => {
         const increment = () => {}
         const initialState = { count: 0, increment }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const originalConsoleError = console.error
         console.error = jest.fn()
 
@@ -294,7 +300,7 @@ describe('when it receives an message of type...', () => {
       const increment = () => {}
       it('it updates state without recording with `message.state`', () => {
         const initialState = { count: 0, increment }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const newState = { foo: 'bar' }
 
         extension.send.mockClear()
@@ -309,7 +315,7 @@ describe('when it receives an message of type...', () => {
 
       it('does not throw for unparsable `message.state`', () => {
         const initialState = { count: 0, increment: () => {} }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const originalConsoleError = console.error
         console.error = jest.fn()
 
@@ -340,7 +346,7 @@ describe('when it receives an message of type...', () => {
     describe('JUMP_TO_ACTION...', () => {
       it('it updates state without recording with `message.state`', () => {
         const initialState = { count: 0, increment: () => {} }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const newState = { foo: 'bar' }
 
         extension.send.mockClear()
@@ -356,7 +362,7 @@ describe('when it receives an message of type...', () => {
       it('does not throw for unparsable `message.state`', () => {
         const increment = () => {}
         const initialState = { count: 0, increment }
-        const api = create(devtools(() => initialState))
+        const api = create(devtools(() => initialState, { enabled: true }))
         const originalConsoleError = console.error
         console.error = jest.fn()
 
@@ -386,7 +392,7 @@ describe('when it receives an message of type...', () => {
 
     it('IMPORT_STATE, it updates state without recording and inits the last computedState', () => {
       const initialState = { count: 0, increment: () => {} }
-      const api = create(devtools(() => initialState))
+      const api = create(devtools(() => initialState, { enabled: true }))
       const nextLiftedState = {
         computedStates: [{ state: { count: 4 } }, { state: { count: 5 } }],
       }
@@ -407,7 +413,7 @@ describe('when it receives an message of type...', () => {
     })
 
     it('PAUSE_RECORDING, it toggles the sending of actions', () => {
-      const api = create(devtools(() => ({ count: 0 })))
+      const api = create(devtools(() => ({ count: 0 }), { enabled: true }))
 
       api.setState({ count: 1 }, false, 'increment')
       expect(extension.send).toHaveBeenLastCalledWith(
@@ -457,7 +463,8 @@ describe('with redux middleware', () => {
             count: count + (type === 'INCREMENT' ? 1 : -1),
           }),
           { count: 0 }
-        )
+        ),
+        { enabled: true }
       )
     )
     ;(api as any).dispatch({ type: 'INCREMENT' })
@@ -494,7 +501,7 @@ it('works in non-browser env', () => {
   global.window = undefined as any
 
   expect(() => {
-    create(devtools(() => ({ count: 0 })))
+    create(devtools(() => ({ count: 0 }), { enabled: true }))
   }).not.toThrow()
 
   global.window = originalWindow
@@ -505,14 +512,14 @@ it('works in react native env', () => {
   global.window = {} as any
 
   expect(() => {
-    create(devtools(() => ({ count: 0 })))
+    create(devtools(() => ({ count: 0 }), { enabled: true }))
   }).not.toThrow()
 
   global.window = originalWindow
 })
 
 it('preserves isRecording after setting from devtools', () => {
-  const api = create(devtools(() => ({ count: 0 })))
+  const api = create(devtools(() => ({ count: 0 }), { enabled: true }))
   ;(extensionSubscriber as (message: any) => void)({
     type: 'DISPATCH',
     payload: { type: 'PAUSE_RECORDING' },
diff --git a/tests/middlewareTypes.test.tsx b/tests/middlewareTypes.test.tsx
index 6e29d1c..4ecdf08 100644
--- a/tests/middlewareTypes.test.tsx
+++ b/tests/middlewareTypes.test.tsx
@@ -2,11 +2,11 @@ import create, { State, StoreApi } from 'zustand'
 import {
   combine,
   devtools,
-  immer,
   persist,
   redux,
   subscribeWithSelector,
 } from 'zustand/middleware'
+import { immer } from 'zustand/middleware/immer'
 import createVanilla from 'zustand/vanilla'
 
 type CounterState = {