mirror of
https://github.com/pmndrs/zustand.git
synced 2025-12-08 19:45:52 +00:00
Compare commits
11 Commits
14feb61c7e
...
a03acc3887
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03acc3887 | ||
|
|
01749b01c7 | ||
|
|
ceb9744ac7 | ||
|
|
ad32cdf7a0 | ||
|
|
670b60e19a | ||
|
|
6953c29dc5 | ||
|
|
5df8085b0b | ||
|
|
b4177b3172 | ||
|
|
a56a3e4bde | ||
|
|
37878da019 | ||
|
|
9a58ca9c95 |
4
.github/workflows/test-multiple-versions.yml
vendored
4
.github/workflows/test-multiple-versions.yml
vendored
@ -19,8 +19,8 @@ jobs:
|
||||
- 18.3.1
|
||||
- 19.0.0
|
||||
- 19.1.0
|
||||
- 19.2.0-canary-e9db3cc2-20250501
|
||||
- 0.0.0-experimental-e9db3cc2-20250501
|
||||
- 19.2.0-canary-c4676e72-20250520
|
||||
- 0.0.0-experimental-c4676e72-20250520
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
@ -25,6 +25,7 @@ const equal = shallow(a, b)
|
||||
- [Comparing Maps](#comparing-maps)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Comparing objects returns `false` even if they are identical.](#comparing-objects-returns-false-even-if-they-are-identical)
|
||||
- [Comparing objects with different prototypes](#comparing-objects-with-different-prototypes)
|
||||
|
||||
## Types
|
||||
|
||||
@ -224,3 +225,24 @@ In this modified example, `objectLeft` and `objectRight` have the same top-level
|
||||
primitive values. Since `shallow` function only compares the top-level properties, it will return
|
||||
`true` because the primitive values (`firstName`, `lastName`, and `age`) are identical in both
|
||||
objects.
|
||||
|
||||
### Comparing objects with different prototypes
|
||||
|
||||
The `shallow` function checks whether the two objects have the same prototype. If their prototypes
|
||||
are referentially different, shallow will return `false`. This comparison is done using:
|
||||
|
||||
```ts
|
||||
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Objects created with the object initializer (`{}`) or with `new Object()` inherit from
|
||||
> `Object.prototype` by default. However, objects created with `Object.create(proto)` inherit from
|
||||
> the proto you pass in—which may not be `Object.prototype.`
|
||||
|
||||
```ts
|
||||
const a = Object.create({}) // -> prototype is `{}`
|
||||
const b = {} // -> prototype is `Object.prototype`
|
||||
|
||||
shallow(a, b) // -> false
|
||||
```
|
||||
|
||||
@ -24,6 +24,7 @@ const nextStateCreatorFn = devtools(stateCreatorFn, devtoolsOptions)
|
||||
- [Usage](#usage)
|
||||
- [Debugging a store](#debugging-a-store)
|
||||
- [Debugging a Slices pattern based store](#debugging-a-slices-pattern-based-store)
|
||||
- [Cleanup](#cleanup)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Only one store is displayed](#only-one-store-is-displayed)
|
||||
- [Action names are labeled as 'anonymous'](#all-action-names-are-labeled-as-anonymous)
|
||||
@ -57,8 +58,8 @@ devtools<T>(stateCreatorFn: StateCreator<T, [], []>, devtoolsOptions?: DevtoolsO
|
||||
- **optional** `enabled`: Defaults to `true` when is on development mode, and defaults to `false`
|
||||
when is on production mode. Enables or disables the Redux DevTools integration
|
||||
for this store.
|
||||
- **optional** `anonymousActionType`: Defaults to `anonymous`. A string to use as the action type
|
||||
for anonymous mutations in the Redux DevTools.
|
||||
- **optional** `anonymousActionType`: Defaults to the inferred action type or `anonymous` if
|
||||
unavailable. A string to use as the action type for anonymous mutations in the Redux DevTools.
|
||||
- **optional** `store`: A custom identifier for the store in the Redux DevTools.
|
||||
|
||||
#### Returns
|
||||
@ -156,6 +157,27 @@ const useJungleStore = create<JungleStore>()(
|
||||
)
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
When a store is no longer needed, you can clean up the Redux DevTools connection by calling the `cleanup` method on the store:
|
||||
|
||||
```ts
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
const useStore = create(
|
||||
devtools((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
})),
|
||||
)
|
||||
|
||||
// When you're done with the store, clean it up
|
||||
useStore.devtools.cleanup()
|
||||
```
|
||||
|
||||
This is particularly useful in applications that wrap store in context or create multiple stores dynamically.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Only one store is displayed
|
||||
|
||||
@ -3,7 +3,6 @@ import vitest from '@vitest/eslint-plugin'
|
||||
import importPlugin from 'eslint-plugin-import'
|
||||
import jestDom from 'eslint-plugin-jest-dom'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import testingLibrary from 'eslint-plugin-testing-library'
|
||||
import tseslint from 'typescript-eslint'
|
||||
@ -17,8 +16,7 @@ export default tseslint.config(
|
||||
tseslint.configs.recommended,
|
||||
react.configs.flat.recommended,
|
||||
react.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactCompiler.configs.recommended,
|
||||
reactHooks.configs.recommended,
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
@ -72,6 +70,7 @@ export default tseslint.config(
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'react-hooks/react-compiler': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -90,10 +89,4 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
35
package.json
35
package.json
@ -3,7 +3,7 @@
|
||||
"description": "🐻 Bear necessities for state management in React",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"version": "5.0.4",
|
||||
"version": "5.0.5",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"typesVersions": {
|
||||
@ -116,7 +116,7 @@
|
||||
"homepage": "https://github.com/pmndrs/zustand",
|
||||
"packageManager": "pnpm@9.15.5",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@rollup/plugin-alias": "^5.1.1",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
@ -124,22 +124,21 @@
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@types/use-sync-external-store": "^1.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"@vitest/eslint-plugin": "^1.1.44",
|
||||
"@vitest/ui": "^3.1.2",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "9.25.1",
|
||||
"eslint-import-resolver-typescript": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/eslint-plugin": "^1.2.0",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"esbuild": "^0.25.4",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-import-resolver-typescript": "^4.3.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-testing-library": "^7.1.1",
|
||||
"eslint-plugin-react-hooks": "6.0.0-rc.1",
|
||||
"eslint-plugin-testing-library": "^7.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"json": "^11.0.0",
|
||||
@ -147,15 +146,15 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"redux": "^5.0.1",
|
||||
"rollup": "^4.40.1",
|
||||
"rollup": "^4.41.0",
|
||||
"rollup-plugin-esbuild": "^6.2.1",
|
||||
"shelljs": "^0.9.2",
|
||||
"shelljs": "^0.10.0",
|
||||
"shx": "^0.4.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.31.1",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"use-sync-external-store": "^1.5.0",
|
||||
"vitest": "^3.1.2"
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
|
||||
1177
pnpm-lock.yaml
generated
1177
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
import type {} from '@redux-devtools/extension'
|
||||
|
||||
import type {
|
||||
StateCreator,
|
||||
StoreApi,
|
||||
@ -65,6 +66,9 @@ type StoreDevtools<S> = S extends {
|
||||
? {
|
||||
setState(...args: [...args: TakeTwo<Sa1>, action?: Action]): Sr1
|
||||
setState(...args: [...args: TakeTwo<Sa2>, action?: Action]): Sr2
|
||||
devtools: {
|
||||
cleanup: () => void
|
||||
}
|
||||
}
|
||||
: never
|
||||
|
||||
@ -109,6 +113,7 @@ type ConnectionInformation = {
|
||||
connection: Connection
|
||||
stores: Record<StoreName, StoreInformation>
|
||||
}
|
||||
|
||||
const trackedConnections: Map<ConnectionName, ConnectionInformation> = new Map()
|
||||
|
||||
const getTrackedConnectionState = (
|
||||
@ -146,6 +151,30 @@ const extractConnectionInformation = (
|
||||
return { type: 'tracked' as const, store, ...newConnection }
|
||||
}
|
||||
|
||||
const removeStoreFromTrackedConnections = (
|
||||
name: string | undefined,
|
||||
store: string | undefined,
|
||||
) => {
|
||||
if (store === undefined) return
|
||||
const connectionInfo = trackedConnections.get(name)
|
||||
if (!connectionInfo) return
|
||||
delete connectionInfo.stores[store]
|
||||
if (Object.keys(connectionInfo.stores).length === 0) {
|
||||
trackedConnections.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
const findCallerName = (stack: string | undefined) => {
|
||||
if (!stack) return undefined
|
||||
const traceLines = stack.split('\n')
|
||||
const apiSetStateLineIndex = traceLines.findIndex((traceLine) =>
|
||||
traceLine.includes('api.setState'),
|
||||
)
|
||||
if (apiSetStateLineIndex < 0) return undefined
|
||||
const callerLine = traceLines[apiSetStateLineIndex + 1]?.trim() || ''
|
||||
return /.+ (.+) .+/.exec(callerLine)?.[1]
|
||||
}
|
||||
|
||||
const devtoolsImpl: DevtoolsImpl =
|
||||
(fn, devtoolsOptions = {}) =>
|
||||
(set, get, api) => {
|
||||
@ -178,9 +207,10 @@ const devtoolsImpl: DevtoolsImpl =
|
||||
;(api.setState as any) = ((state, replace, nameOrAction: Action) => {
|
||||
const r = set(state, replace as any)
|
||||
if (!isRecording) return r
|
||||
const inferredActionType = findCallerName(new Error().stack)
|
||||
const action: { type: string } =
|
||||
nameOrAction === undefined
|
||||
? { type: anonymousActionType || 'anonymous' }
|
||||
? { type: anonymousActionType || inferredActionType || 'anonymous' }
|
||||
: typeof nameOrAction === 'string'
|
||||
? { type: nameOrAction }
|
||||
: nameOrAction
|
||||
@ -200,6 +230,17 @@ const devtoolsImpl: DevtoolsImpl =
|
||||
)
|
||||
return r
|
||||
}) as NamedSet<S>
|
||||
;(api as StoreApi<S> & StoreDevtools<S>).devtools = {
|
||||
cleanup: () => {
|
||||
if (
|
||||
connection &&
|
||||
typeof (connection as any).unsubscribe === 'function'
|
||||
) {
|
||||
;(connection as any).unsubscribe()
|
||||
}
|
||||
removeStoreFromTrackedConnections(options.name, store)
|
||||
},
|
||||
}
|
||||
|
||||
const setStateFromDevtools: StoreApi<S>['setState'] = (...a) => {
|
||||
const originalIsRecording = isRecording
|
||||
|
||||
@ -47,18 +47,15 @@ export function createJSONStorage<S>(
|
||||
}
|
||||
return JSON.parse(str, options?.reviver) as StorageValue<S>
|
||||
}
|
||||
const str = (storage as StateStorage).getItem(name) ?? null
|
||||
const str = storage.getItem(name) ?? null
|
||||
if (str instanceof Promise) {
|
||||
return str.then(parse)
|
||||
}
|
||||
return parse(str)
|
||||
},
|
||||
setItem: (name, newValue) =>
|
||||
(storage as StateStorage).setItem(
|
||||
name,
|
||||
JSON.stringify(newValue, options?.replacer),
|
||||
),
|
||||
removeItem: (name) => (storage as StateStorage).removeItem(name),
|
||||
storage.setItem(name, JSON.stringify(newValue, options?.replacer)),
|
||||
removeItem: (name) => storage.removeItem(name),
|
||||
}
|
||||
return persistStorage
|
||||
}
|
||||
|
||||
@ -57,14 +57,18 @@ export function shallow<T>(valueA: T, valueB: T): boolean {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!isIterable(valueA) || !isIterable(valueB)) {
|
||||
return compareEntries(
|
||||
{ entries: () => Object.entries(valueA) },
|
||||
{ entries: () => Object.entries(valueB) },
|
||||
)
|
||||
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
|
||||
return false
|
||||
}
|
||||
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
|
||||
return compareEntries(valueA, valueB)
|
||||
if (isIterable(valueA) && isIterable(valueB)) {
|
||||
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
|
||||
return compareEntries(valueA, valueB)
|
||||
}
|
||||
return compareIterables(valueA, valueB)
|
||||
}
|
||||
return compareIterables(valueA, valueB)
|
||||
// assume plain objects
|
||||
return compareEntries(
|
||||
{ entries: () => Object.entries(valueA) },
|
||||
{ entries: () => Object.entries(valueB) },
|
||||
)
|
||||
}
|
||||
|
||||
@ -98,7 +98,11 @@ const extensionConnector = {
|
||||
subscribers.push(f)
|
||||
return () => {}
|
||||
}),
|
||||
unsubscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(() => {
|
||||
connectionMap.delete(
|
||||
areNameUndefinedMapsNeeded ? options.testConnectionId : key,
|
||||
)
|
||||
}),
|
||||
send: vi.fn(),
|
||||
init: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@ -189,6 +193,37 @@ describe('When state changes...', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('When state changes with automatic setter inferring...', () => {
|
||||
it("sends { type: setStateName || 'setCount`, ...rest } as the action with current state", async () => {
|
||||
const options = {
|
||||
name: 'testOptionsName',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const api = createStore<{
|
||||
count: number
|
||||
setCount: (count: number) => void
|
||||
}>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
count: 0,
|
||||
setCount: (newCount: number) => {
|
||||
set({ count: newCount })
|
||||
},
|
||||
}),
|
||||
options,
|
||||
),
|
||||
)
|
||||
|
||||
api.getState().setCount(10)
|
||||
const [connection] = getNamedConnectionApis(options.name)
|
||||
expect(connection.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'Object.setCount' },
|
||||
{ count: 10, setCount: expect.any(Function) },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when it receives a message of type...', () => {
|
||||
describe('ACTION...', () => {
|
||||
it('does nothing', async () => {
|
||||
@ -2448,3 +2483,40 @@ describe('when create devtools was called multiple times with `name` and `store`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should unsubscribe from devtools when cleanup is called', async () => {
|
||||
const options = { name: 'test' }
|
||||
const store = createStore(devtools(() => ({ count: 0 }), options))
|
||||
const [connection] = getNamedConnectionApis(options.name)
|
||||
store.devtools.cleanup()
|
||||
|
||||
expect(connection.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove store from tracked connection after cleanup', async () => {
|
||||
const options = {
|
||||
name: 'test-store-name',
|
||||
store: 'test-store-id',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const store1 = createStore(devtools(() => ({ count: 0 }), options))
|
||||
store1.devtools.cleanup()
|
||||
const store2 = createStore(devtools(() => ({ count: 0 }), options))
|
||||
|
||||
const [connection] = getNamedConnectionApis(options.name)
|
||||
|
||||
store2.setState({ count: 15 }, false, 'updateCount')
|
||||
expect(connection.send).toHaveBeenLastCalledWith(
|
||||
{ type: `${options.store}/updateCount` },
|
||||
{ [options.store]: { count: 15 } },
|
||||
)
|
||||
|
||||
store1.setState({ count: 20 }, false, 'ignoredAction')
|
||||
expect(connection.send).not.toHaveBeenLastCalledWith(
|
||||
{ type: `${options.store}/ignoredAction` },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint @typescript-eslint/no-unused-expressions: off */ // FIXME
|
||||
/* eslint react-compiler/react-compiler: off */
|
||||
/* eslint react-hooks/react-compiler: off */
|
||||
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
|
||||
@ -70,6 +70,13 @@ it('can get the store', async () => {
|
||||
expect(store.getState().getState2().value).toBe(1)
|
||||
})
|
||||
|
||||
it('can get the initial state', () => {
|
||||
const initial = { value: 1 }
|
||||
const store = createStore(() => initial)
|
||||
store.setState({ value: 2 })
|
||||
expect(store.getInitialState()).toBe(initial)
|
||||
})
|
||||
|
||||
it('can set the store', async () => {
|
||||
type State = {
|
||||
value: number
|
||||
|
||||
@ -172,6 +172,25 @@ describe('shallow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed cases', () => {
|
||||
const obj = { 0: 'foo', 1: 'bar' }
|
||||
const arr = ['foo', 'bar']
|
||||
const set = new Set(['foo', 'bar'])
|
||||
const map = new Map([
|
||||
[0, 'foo'],
|
||||
[1, 'bar'],
|
||||
])
|
||||
|
||||
it('compares different data structures', () => {
|
||||
expect(shallow<unknown>(obj, arr)).toBe(false)
|
||||
expect(shallow<unknown>(obj, set)).toBe(false)
|
||||
expect(shallow<unknown>(obj, map)).toBe(false)
|
||||
expect(shallow<unknown>(arr, set)).toBe(false)
|
||||
expect(shallow<unknown>(arr, map)).toBe(false)
|
||||
expect(shallow<unknown>(set, map)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generators', () => {
|
||||
it('pure iterable', () => {
|
||||
function* gen() {
|
||||
@ -181,6 +200,24 @@ describe('generators', () => {
|
||||
expect(Symbol.iterator in gen()).toBe(true)
|
||||
expect(shallow(gen(), gen())).toBe(true)
|
||||
})
|
||||
|
||||
it('pure iterable with different values returns false', () => {
|
||||
const iterableA = {
|
||||
[Symbol.iterator]: function* (): Generator<number> {
|
||||
yield 1
|
||||
yield 2
|
||||
},
|
||||
}
|
||||
|
||||
const iterableB = {
|
||||
[Symbol.iterator]: function* (): Generator<number> {
|
||||
yield 1
|
||||
yield 3
|
||||
},
|
||||
}
|
||||
|
||||
expect(shallow(iterableA, iterableB)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported cases', () => {
|
||||
|
||||
@ -120,4 +120,15 @@ describe('subscribe()', () => {
|
||||
expect(spy2).toHaveBeenCalledTimes(1)
|
||||
expect(spy2).toHaveBeenCalledWith(1, 0)
|
||||
})
|
||||
|
||||
it('should call listener immediately when fireImmediately is true', () => {
|
||||
const spy = vi.fn()
|
||||
const initialState = { value: 1 }
|
||||
const { subscribe } = createStore(subscribeWithSelector(() => initialState))
|
||||
|
||||
subscribe((s) => s.value, spy, { fireImmediately: true })
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(spy).toHaveBeenCalledWith(1, 1)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user