Skip to content

Commit 43503e6

Browse files
authored
Merge branch 'main' into test/vanilla-shallow-pure-iterable-false
2 parents df758a6 + 5df8085 commit 43503e6

7 files changed

Lines changed: 147 additions & 15 deletions

File tree

docs/apis/shallow.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const equal = shallow(a, b)
2525
- [Comparing Maps](#comparing-maps)
2626
- [Troubleshooting](#troubleshooting)
2727
- [Comparing objects returns `false` even if they are identical.](#comparing-objects-returns-false-even-if-they-are-identical)
28+
- [Comparing objects with different prototypes](#comparing-objects-with-different-prototypes)
2829

2930
## Types
3031

@@ -224,3 +225,24 @@ In this modified example, `objectLeft` and `objectRight` have the same top-level
224225
primitive values. Since `shallow` function only compares the top-level properties, it will return
225226
`true` because the primitive values (`firstName`, `lastName`, and `age`) are identical in both
226227
objects.
228+
229+
### Comparing objects with different prototypes
230+
231+
The `shallow` function checks whether the two objects have the same prototype. If their prototypes
232+
are referentially different, shallow will return `false`. This comparison is done using:
233+
234+
```ts
235+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
236+
```
237+
238+
> [!IMPORTANT]
239+
> Objects created with the object initializer (`{}`) or with `new Object()` inherit from
240+
> `Object.prototype` by default. However, objects created with `Object.create(proto)` inherit from
241+
> the proto you pass in—which may not be `Object.prototype.`
242+
243+
```ts
244+
const a = Object.create({}) // -> prototype is `{}`
245+
const b = {} // -> prototype is `Object.prototype`
246+
247+
shallow(a, b) // -> false
248+
```

docs/middlewares/devtools.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const nextStateCreatorFn = devtools(stateCreatorFn, devtoolsOptions)
2424
- [Usage](#usage)
2525
- [Debugging a store](#debugging-a-store)
2626
- [Debugging a Slices pattern based store](#debugging-a-slices-pattern-based-store)
27+
- [Cleanup](#cleanup)
2728
- [Troubleshooting](#troubleshooting)
2829
- [Only one store is displayed](#only-one-store-is-displayed)
2930
- [Action names are labeled as 'anonymous'](#all-action-names-are-labeled-as-anonymous)
@@ -156,6 +157,27 @@ const useJungleStore = create<JungleStore>()(
156157
)
157158
```
158159

160+
### Cleanup
161+
162+
When a store is no longer needed, you can clean up the Redux DevTools connection by calling the `cleanup` method on the store:
163+
164+
```ts
165+
import { create } from 'zustand'
166+
import { devtools } from 'zustand/middleware'
167+
168+
const useStore = create(
169+
devtools((set) => ({
170+
count: 0,
171+
increment: () => set((state) => ({ count: state.count + 1 })),
172+
})),
173+
)
174+
175+
// When you're done with the store, clean it up
176+
useStore.devtools.cleanup()
177+
```
178+
179+
This is particularly useful in applications that wrap store in context or create multiple stores dynamically.
180+
159181
## Troubleshooting
160182

161183
### Only one store is displayed

src/middleware/devtools.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ type StoreDevtools<S> = S extends {
6565
? {
6666
setState(...args: [...args: TakeTwo<Sa1>, action?: Action]): Sr1
6767
setState(...args: [...args: TakeTwo<Sa2>, action?: Action]): Sr2
68+
devtools: {
69+
cleanup: () => void
70+
}
6871
}
6972
: never
7073

@@ -146,6 +149,19 @@ const extractConnectionInformation = (
146149
return { type: 'tracked' as const, store, ...newConnection }
147150
}
148151

152+
const removeStoreFromTrackedConnections = (
153+
name: string | undefined,
154+
store: string | undefined,
155+
) => {
156+
if (store === undefined) return
157+
const connectionInfo = trackedConnections.get(name)
158+
if (!connectionInfo) return
159+
delete connectionInfo.stores[store]
160+
if (Object.keys(connectionInfo.stores).length === 0) {
161+
trackedConnections.delete(name)
162+
}
163+
}
164+
149165
const devtoolsImpl: DevtoolsImpl =
150166
(fn, devtoolsOptions = {}) =>
151167
(set, get, api) => {
@@ -200,6 +216,17 @@ const devtoolsImpl: DevtoolsImpl =
200216
)
201217
return r
202218
}) as NamedSet<S>
219+
;(api as StoreApi<S> & StoreDevtools<S>).devtools = {
220+
cleanup: () => {
221+
if (
222+
connection &&
223+
typeof (connection as any).unsubscribe === 'function'
224+
) {
225+
;(connection as any).unsubscribe()
226+
}
227+
removeStoreFromTrackedConnections(options.name, store)
228+
},
229+
}
203230

204231
const setStateFromDevtools: StoreApi<S>['setState'] = (...a) => {
205232
const originalIsRecording = isRecording

src/middleware/persist.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,15 @@ export function createJSONStorage<S>(
4747
}
4848
return JSON.parse(str, options?.reviver) as StorageValue<S>
4949
}
50-
const str = (storage as StateStorage).getItem(name) ?? null
50+
const str = storage.getItem(name) ?? null
5151
if (str instanceof Promise) {
5252
return str.then(parse)
5353
}
5454
return parse(str)
5555
},
5656
setItem: (name, newValue) =>
57-
(storage as StateStorage).setItem(
58-
name,
59-
JSON.stringify(newValue, options?.replacer),
60-
),
61-
removeItem: (name) => (storage as StateStorage).removeItem(name),
57+
storage.setItem(name, JSON.stringify(newValue, options?.replacer)),
58+
removeItem: (name) => storage.removeItem(name),
6259
}
6360
return persistStorage
6461
}

src/vanilla/shallow.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,18 @@ export function shallow<T>(valueA: T, valueB: T): boolean {
5757
) {
5858
return false
5959
}
60-
if (!isIterable(valueA) || !isIterable(valueB)) {
61-
return compareEntries(
62-
{ entries: () => Object.entries(valueA) },
63-
{ entries: () => Object.entries(valueB) },
64-
)
60+
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
61+
return false
6562
}
66-
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
67-
return compareEntries(valueA, valueB)
63+
if (isIterable(valueA) && isIterable(valueB)) {
64+
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
65+
return compareEntries(valueA, valueB)
66+
}
67+
return compareIterables(valueA, valueB)
6868
}
69-
return compareIterables(valueA, valueB)
69+
// assume plain objects
70+
return compareEntries(
71+
{ entries: () => Object.entries(valueA) },
72+
{ entries: () => Object.entries(valueB) },
73+
)
7074
}

tests/devtools.test.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ const extensionConnector = {
9898
subscribers.push(f)
9999
return () => {}
100100
}),
101-
unsubscribe: vi.fn(),
101+
unsubscribe: vi.fn(() => {
102+
connectionMap.delete(
103+
areNameUndefinedMapsNeeded ? options.testConnectionId : key,
104+
)
105+
}),
102106
send: vi.fn(),
103107
init: vi.fn(),
104108
error: vi.fn(),
@@ -2448,3 +2452,40 @@ describe('when create devtools was called multiple times with `name` and `store`
24482452
})
24492453
})
24502454
})
2455+
2456+
describe('cleanup', () => {
2457+
it('should unsubscribe from devtools when cleanup is called', async () => {
2458+
const options = { name: 'test' }
2459+
const store = createStore(devtools(() => ({ count: 0 }), options))
2460+
const [connection] = getNamedConnectionApis(options.name)
2461+
store.devtools.cleanup()
2462+
2463+
expect(connection.unsubscribe).toHaveBeenCalledTimes(1)
2464+
})
2465+
2466+
it('should remove store from tracked connection after cleanup', async () => {
2467+
const options = {
2468+
name: 'test-store-name',
2469+
store: 'test-store-id',
2470+
enabled: true,
2471+
}
2472+
2473+
const store1 = createStore(devtools(() => ({ count: 0 }), options))
2474+
store1.devtools.cleanup()
2475+
const store2 = createStore(devtools(() => ({ count: 0 }), options))
2476+
2477+
const [connection] = getNamedConnectionApis(options.name)
2478+
2479+
store2.setState({ count: 15 }, false, 'updateCount')
2480+
expect(connection.send).toHaveBeenLastCalledWith(
2481+
{ type: `${options.store}/updateCount` },
2482+
{ [options.store]: { count: 15 } },
2483+
)
2484+
2485+
store1.setState({ count: 20 }, false, 'ignoredAction')
2486+
expect(connection.send).not.toHaveBeenLastCalledWith(
2487+
{ type: `${options.store}/ignoredAction` },
2488+
expect.anything(),
2489+
)
2490+
})
2491+
})

tests/vanilla/shallow.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ describe('shallow', () => {
172172
})
173173
})
174174

175+
describe('mixed cases', () => {
176+
const obj = { 0: 'foo', 1: 'bar' }
177+
const arr = ['foo', 'bar']
178+
const set = new Set(['foo', 'bar'])
179+
const map = new Map([
180+
[0, 'foo'],
181+
[1, 'bar'],
182+
])
183+
184+
it('compares different data structures', () => {
185+
expect(shallow<unknown>(obj, arr)).toBe(false)
186+
expect(shallow<unknown>(obj, set)).toBe(false)
187+
expect(shallow<unknown>(obj, map)).toBe(false)
188+
expect(shallow<unknown>(arr, set)).toBe(false)
189+
expect(shallow<unknown>(arr, map)).toBe(false)
190+
expect(shallow<unknown>(set, map)).toBe(false)
191+
})
192+
})
193+
175194
describe('generators', () => {
176195
it('pure iterable', () => {
177196
function* gen() {

0 commit comments

Comments
 (0)