Repository: pmndrs/zustand
Pull request #894 (2022-04-06 06:06:51)

fix(middleware/persist): Merge storage value with the latest store state
When using persist middleware, it's possible to mutate the store state in an useEffect callback before hydration creates `stateFromStorage`. It should merge the storage value with the latest store state instead of the config result since it can be out of date.

To reproduce:
1. create a storage whose getItem returns the value after a 2sec timeout. The storage is responsible for persisting a partial state. (property a)
2. add a useEffect to update the state right after render. It adds another property to the store. (property b)

Expected: When the storage value is fetched, it merges storage value with the latest store value.
Actual: When the storage value is fetched, it merges storage value with the initial store value.

Error demo: https://codesandbox.io/s/react-forked-oo16v5?file=/src/App.js
Fixed demo: https://codesandbox.io/s/react-forked-hydp10?file=/src/App.js



codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/pmndrs/zustand/pr/894/builds/236361) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 27a76b5af0457d1098839e691865e2a70f81c13d:

|Sandbox| Source |
|--|--|
|[React](https://codesandbox.io/s/react-ddhz1b)| Configuration |
|[React Typescript](https://codesandbox.io/s/react-typescript-8kquci)| Configuration |
|[React Browserify](https://codesandbox.io/s/react-browserify-t6ws7f)| Configuration |
|[React Snowpack](https://codesandbox.io/s/react-snowpack-dtohnt)| Configuration |
|[React Parcel](https://codesandbox.io/s/react-parcel-3whpzb)| Configuration |
|[Next.js](https://codesandbox.io/s/next-js-i6ofy1)| Configuration |


Patch:
diff --git a/src/middleware/persist.ts b/src/middleware/persist.ts
index b11bae0..32b3e29 100644
--- a/src/middleware/persist.ts
+++ b/src/middleware/persist.ts
@@ -312,7 +312,10 @@ export const persist =
           }
         })
         .then((migratedState) => {
-          stateFromStorage = options.merge(migratedState as S, configResult)
+          stateFromStorage = options.merge(
+            migratedState as S,
+            get() ?? configResult
+          )
 
           set(stateFromStorage as S, true)
           return setItem()


Test patch:
diff --git a/tests/persistAsync.test.tsx b/tests/persistAsync.test.tsx
index 592dfe7..3b888f1 100644
--- a/tests/persistAsync.test.tsx
+++ b/tests/persistAsync.test.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from 'react'
 import { act, render, waitFor } from '@testing-library/react'
 import create from 'zustand'
 import { persist } from 'zustand/middleware'
@@ -217,6 +218,58 @@ describe('persist middleware with async configuration', () => {
     expect(onRehydrateStorageSpy).toBeCalledWith({ count: 99 }, undefined)
   })
 
+  it('can merge partial persisted state', async () => {
+    const storage = {
+      getItem: async () =>
+        JSON.stringify({
+          state: { count: 42 },
+        }),
+      setItem: () => {},
+      removeItem: () => {},
+    }
+
+    const useStore = create(
+      persist(
+        (set) => ({
+          count: 0,
+          name: 'unknown',
+          setName: (name: string) => {
+            set({ name })
+          },
+        }),
+        {
+          name: 'test-storage',
+          getStorage: () => storage,
+        }
+      )
+    )
+
+    function Component() {
+      const { count, setName, name } = useStore()
+      useEffect(() => {
+        setName('test')
+      }, [setName])
+      return (
+        <div>
+          <div>count: {count}</div>
+          <div>name: {name}</div>
+        </div>
+      )
+    }
+
+    const { findByText } = render(<Component />)
+
+    await findByText('count: 42')
+    await findByText('name: test')
+
+    expect(useStore.getState()).toEqual(
+      expect.objectContaining({
+        count: 42,
+        name: 'test',
+      })
+    )
+  })
+
   it('can correclty handle a missing migrate function', async () => {
     console.error = jest.fn()
     const onRehydrateStorageSpy = jest.fn()