Compare commits

...

11 Commits

Author SHA1 Message Date
Wonsuk Choi
a03acc3887
test(vanilla/subscribe): add test for covering fireImmediately option in 'subscribeWithSelector' (#3131) 2025-05-22 12:58:34 +09:00
daishi
01749b01c7 5.0.5 2025-05-21 21:05:14 +09:00
Daishi Kato
ceb9744ac7
chore: update dev dependencies (#3128) 2025-05-21 20:47:28 +09:00
Wonsuk Choi
ad32cdf7a0
test(vanilla/basic): add test for 'getInitialState' (#3130)
* test(vanilla/basic): add test for 'getInitialState'

* Update tests/vanilla/basic.test.ts

* Update tests/vanilla/basic.test.ts

---------

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2025-05-21 20:22:09 +09:00
Ali Mert Çakar
670b60e19a
feat(devtool): inferred action type (#2987)
* feat(devtools middleware) add automatic action name finding

* docs(readme) add inferActionName example

* feat: update readmes

* feat: update devtools middleware and tests

* feat: remove inferActionName

---------

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
Co-authored-by: Danilo Britto <dbritto.dev@gmail.com>
2025-05-21 20:12:14 +09:00
Wonsuk Choi
6953c29dc5
test(vanilla/shallow): add test for pure iterable with different values returns false (#3129) 2025-05-21 11:41:09 +09:00
2yunseong
5df8085b0b
refactor: remove unnessary type assertion (#3113)
Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2025-05-21 09:52:37 +09:00
Hong-Kuan Wu
b4177b3172
feat: add devtools.cleanup() method (#3111)
* feat(devtools): add cleanup method

* docs(devtools): add cleanup section

* reduce lines

* test(devtools): test if the connection removed after cleanup

---------

Co-authored-by: daishi <daishi@axlight.com>
Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2025-05-21 09:49:59 +09:00
Danilo Britto
a56a3e4bde
feat(vanilla): update shallow compare function and tests (#3108)
* feat(vanilla): update shallow compare function and tests

* prefer Object.getPrototypeOf

* docs: update shallow docs

* feat: minor fixes

---------

Co-authored-by: daishi <daishi@axlight.com>
Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2025-05-21 09:46:37 +09:00
Wonsuk Choi
37878da019
chore(eslint): remove overrides for .js files (#3126) 2025-05-20 15:29:27 +09:00
Wonsuk Choi
9a58ca9c95
chore(eslint): update to 'eslint-plugin-react-hooks@6.0.0-rc.1', remove 'eslint-plugin-react-compiler' (#3122) 2025-05-17 14:13:12 +09:00
14 changed files with 876 additions and 602 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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

View File

@ -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',
},
},
)

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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
}

View File

@ -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) },
)
}

View File

@ -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(),
)
})
})

View File

@ -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'

View File

@ -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

View File

@ -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', () => {

View File

@ -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)
})
})