fix(shallow): Extract shallow vanilla and react (#2097)

* Update readmes

* Splitting shallow in two modules

* Update tests

* Minor changes

* Minor changes

* Rename shadow.tests.tsx to shallow.test.tsx

* Add new entrypoint for shallow/react

* Update structure

* Update shallow to export from vanilla and react

* Add vanilla/shallow and react/shallow entrypoints

* Update tests

* Update readmes

* Update src/shallow.ts

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>

* Minor changes

* Update readmes

* Update readmes

* Update tests

* Minor changes

---------

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
This commit is contained in:
Danilo Britto 2023-10-05 09:04:56 -05:00 committed by GitHub
parent 2be79c9154
commit e414f7ccf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 332 additions and 166 deletions

View File

@ -45,7 +45,7 @@ We can fix that using `useShallow`!
```js
import { create } from 'zustand'
import { useShallow } from 'zustand/shallow'
import { useShallow } from 'zustand/react/shallow'
const useMeals = create(() => ({
papaBear: 'large porridge-pot',

View File

@ -65,6 +65,24 @@
"module": "./esm/shallow.js",
"default": "./shallow.js"
},
"./vanilla/shallow": {
"types": "./vanilla/shallow.d.ts",
"import": {
"types": "./esm/vanilla/shallow.d.mts",
"default": "./esm/vanilla/shallow.mjs"
},
"module": "./esm/vanilla/shallow.js",
"default": "./vanilla/shallow.js"
},
"./react/shallow": {
"types": "./react/shallow.d.ts",
"import": {
"types": "./esm/react/shallow.d.mts",
"default": "./esm/react/shallow.mjs"
},
"module": "./esm/react/shallow.js",
"default": "./react/shallow.js"
},
"./traditional": {
"types": "./traditional.d.ts",
"import": {
@ -93,6 +111,8 @@
"build:middleware": "rollup -c --config-middleware",
"build:middleware:immer": "rollup -c --config-middleware_immer",
"build:shallow": "rollup -c --config-shallow",
"build:vanilla:shallow": "rollup -c --config-vanilla_shallow",
"build:react:shallow": "rollup -c --config-react_shallow",
"build:traditional": "rollup -c --config-traditional",
"build:context": "rollup -c --config-context",
"postbuild": "yarn patch-d-ts && yarn copy && yarn patch-esm-ts",

View File

@ -88,7 +88,7 @@ If you want to construct a single object with multiple state-picks inside, simil
```jsx
import { create } from 'zustand'
import { useShallow } from 'zustand/shallow'
import { useShallow } from 'zustand/react/shallow'
const useBearStore = create((set) => ({
bears: 0,

13
src/react/shallow.ts Normal file
View File

@ -0,0 +1,13 @@
import { useRef } from 'react'
import { shallow } from '../vanilla/shallow.ts'
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U {
const prev = useRef<U>()
return (state) => {
const next = selector(state)
return shallow(prev.current, next)
? (prev.current as U)
: (prev.current = next)
}
}

View File

@ -1,54 +1,8 @@
import { useRef } from 'react'
import { shallow } from './vanilla/shallow.ts'
export function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false
for (const [key, value] of objA) {
if (!Object.is(value, objB.get(key))) {
return false
}
}
return true
}
if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false
for (const value of objA) {
if (!objB.has(value)) {
return false
}
}
return true
}
const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) ||
!Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T])
) {
return false
}
}
return true
}
// We will export this in v5 and remove default export
// export { shallow } from './vanilla/shallow.ts'
// export { useShallow } from './react/shallow.ts'
/**
* @deprecated Use `import { shallow } from 'zustand/shallow'`
@ -62,13 +16,4 @@ export default ((objA, objB) => {
return shallow(objA, objB)
}) as typeof shallow
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U {
const prev = useRef<U>()
return (state) => {
const next = selector(state)
return shallow(prev.current, next)
? (prev.current as U)
: (prev.current = next)
}
}
export { shallow }

49
src/vanilla/shallow.ts Normal file
View File

@ -0,0 +1,49 @@
export function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false
for (const [key, value] of objA) {
if (!Object.is(value, objB.get(key))) {
return false
}
}
return true
}
if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false
for (const value of objA) {
if (!objB.has(value)) {
return false
}
}
return true
}
const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) ||
!Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T])
) {
return false
}
}
return true
}

View File

@ -2,99 +2,8 @@ import { useState } from 'react'
import { act, fireEvent, render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { shallow, useShallow } from 'zustand/shallow'
describe('shallow', () => {
it('compares primitive values', () => {
expect(shallow(true, true)).toBe(true)
expect(shallow(true, false)).toBe(false)
expect(shallow(1, 1)).toBe(true)
expect(shallow(1, 2)).toBe(false)
expect(shallow('zustand', 'zustand')).toBe(true)
expect(shallow('zustand', 'redux')).toBe(false)
})
it('compares objects', () => {
expect(shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123 })).toBe(
true
)
expect(
shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', foobar: true })
).toBe(false)
expect(
shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123, foobar: true })
).toBe(false)
})
it('compares arrays', () => {
expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true)
expect(shallow([1, 2, 3], [2, 3, 4])).toBe(false)
expect(
shallow([{ foo: 'bar' }, { asd: 123 }], [{ foo: 'bar' }, { asd: 123 }])
).toBe(false)
expect(shallow([{ foo: 'bar' }], [{ foo: 'bar', asd: 123 }])).toBe(false)
})
it('compares Maps', () => {
function createMap<T extends object>(obj: T) {
return new Map(Object.entries(obj))
}
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', asd: 123 })
)
).toBe(true)
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', foobar: true })
)
).toBe(false)
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', asd: 123, foobar: true })
)
).toBe(false)
})
it('compares Sets', () => {
expect(shallow(new Set(['bar', 123]), new Set(['bar', 123]))).toBe(true)
expect(shallow(new Set(['bar', 123]), new Set(['bar', 2]))).toBe(false)
expect(shallow(new Set(['bar', 123]), new Set(['bar', 123, true]))).toBe(
false
)
})
it('compares functions', () => {
function firstFnCompare() {
return { foo: 'bar' }
}
function secondFnCompare() {
return { foo: 'bar' }
}
expect(shallow(firstFnCompare, firstFnCompare)).toBe(true)
expect(shallow(secondFnCompare, secondFnCompare)).toBe(true)
expect(shallow(firstFnCompare, secondFnCompare)).toBe(false)
})
})
import { useShallow } from 'zustand/react/shallow'
import { shallow } from 'zustand/vanilla/shallow'
describe('types', () => {
it('works with useBoundStore and array selector (#1107)', () => {
@ -123,17 +32,6 @@ describe('types', () => {
})
})
describe('unsupported cases', () => {
it('date', () => {
expect(
shallow(
new Date('2022-07-19T00:00:00.000Z'),
new Date('2022-07-20T00:00:00.000Z')
)
).not.toBe(false)
})
})
describe('useShallow', () => {
const testUseShallowSimpleCallback =
vi.fn<[{ selectorOutput: string[]; useShallowOutput: string[] }]>()

136
tests/vanilla/basic.test.ts Normal file
View File

@ -0,0 +1,136 @@
import { afterEach, expect, it, vi } from 'vitest'
import { createStore } from 'zustand/vanilla'
import type { StoreApi } from 'zustand/vanilla'
// To avoid include react deps on vanilla version
vi.mock('react', () => ({}))
const consoleError = console.error
afterEach(() => {
console.error = consoleError
})
it('create a store', () => {
let params
const result = createStore((...args) => {
params = args
return { value: null }
})
expect({ params, result }).toMatchInlineSnapshot(`
{
"params": [
[Function],
[Function],
{
"destroy": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
},
],
"result": {
"destroy": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
},
}
`)
})
type CounterState = {
count: number
inc: () => void
}
it('uses the store', async () => {
const store = createStore<CounterState>((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
store.getState().inc()
expect(store.getState().count).toBe(1)
})
it('can get the store', async () => {
type State = {
value: number
getState1: () => State
getState2: () => State
}
const store = createStore<State>((_, get) => ({
value: 1,
getState1: () => get(),
getState2: (): State => store.getState(),
}))
expect(store.getState().getState1().value).toBe(1)
expect(store.getState().getState2().value).toBe(1)
})
it('can set the store', async () => {
type State = {
value: number
setState1: StoreApi<State>['setState']
setState2: StoreApi<State>['setState']
}
const store = createStore<State>((set) => ({
value: 1,
setState1: (v) => set(v),
setState2: (v): void => store.setState(v),
}))
store.getState().setState1({ value: 2 })
expect(store.getState().value).toBe(2)
store.getState().setState2({ value: 3 })
expect(store.getState().value).toBe(3)
})
it('both NaN should not update', () => {
const store = createStore<number>(() => NaN)
const fn = vi.fn()
store.subscribe(fn)
store.setState(NaN)
expect(fn).not.toBeCalled()
})
it('can set the store without merging', () => {
const { setState, getState } = createStore<{ a: number } | { b: number }>(
(_set) => ({
a: 1,
})
)
// Should override the state instead of merging.
setState({ b: 2 }, true)
expect(getState()).toEqual({ b: 2 })
})
it('works with non-object state', () => {
const store = createStore<number>(() => 1)
const inc = () => store.setState((c) => c + 1)
inc()
expect(store.getState()).toBe(2)
})
it('can destroy the store', () => {
const { destroy, getState, setState, subscribe } = createStore(() => ({
value: 1,
}))
subscribe(() => {
throw new Error('did not clear listener on destroy')
})
destroy()
setState({ value: 2 })
expect(getState().value).toEqual(2)
})

View File

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { shallow } from 'zustand/vanilla/shallow'
describe('shallow', () => {
it('compares primitive values', () => {
expect(shallow(true, true)).toBe(true)
expect(shallow(true, false)).toBe(false)
expect(shallow(1, 1)).toBe(true)
expect(shallow(1, 2)).toBe(false)
expect(shallow('zustand', 'zustand')).toBe(true)
expect(shallow('zustand', 'redux')).toBe(false)
})
it('compares objects', () => {
expect(shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123 })).toBe(
true
)
expect(
shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', foobar: true })
).toBe(false)
expect(
shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123, foobar: true })
).toBe(false)
})
it('compares arrays', () => {
expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true)
expect(shallow([1, 2, 3], [2, 3, 4])).toBe(false)
expect(
shallow([{ foo: 'bar' }, { asd: 123 }], [{ foo: 'bar' }, { asd: 123 }])
).toBe(false)
expect(shallow([{ foo: 'bar' }], [{ foo: 'bar', asd: 123 }])).toBe(false)
})
it('compares Maps', () => {
function createMap<T extends object>(obj: T) {
return new Map(Object.entries(obj))
}
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', asd: 123 })
)
).toBe(true)
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', foobar: true })
)
).toBe(false)
expect(
shallow(
createMap({ foo: 'bar', asd: 123 }),
createMap({ foo: 'bar', asd: 123, foobar: true })
)
).toBe(false)
})
it('compares Sets', () => {
expect(shallow(new Set(['bar', 123]), new Set(['bar', 123]))).toBe(true)
expect(shallow(new Set(['bar', 123]), new Set(['bar', 2]))).toBe(false)
expect(shallow(new Set(['bar', 123]), new Set(['bar', 123, true]))).toBe(
false
)
})
it('compares functions', () => {
function firstFnCompare() {
return { foo: 'bar' }
}
function secondFnCompare() {
return { foo: 'bar' }
}
expect(shallow(firstFnCompare, firstFnCompare)).toBe(true)
expect(shallow(secondFnCompare, secondFnCompare)).toBe(true)
expect(shallow(firstFnCompare, secondFnCompare)).toBe(false)
})
})
describe('unsupported cases', () => {
it('date', () => {
expect(
shallow(
new Date('2022-07-19T00:00:00.000Z'),
new Date('2022-07-20T00:00:00.000Z')
)
).not.toBe(false)
})
})