diff --git a/docs/guides/prevent-rerenders-with-use-shallow.md b/docs/guides/prevent-rerenders-with-use-shallow.md index c8e667dbb5..3c867fcbed 100644 --- a/docs/guides/prevent-rerenders-with-use-shallow.md +++ b/docs/guides/prevent-rerenders-with-use-shallow.md @@ -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', diff --git a/package.json b/package.json index 8abc8fffd7..8cbb8c732e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/readme.md b/readme.md index 80c73071f4..9214c1b72d 100644 --- a/readme.md +++ b/readme.md @@ -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, diff --git a/src/react/shallow.ts b/src/react/shallow.ts new file mode 100644 index 0000000000..234f905190 --- /dev/null +++ b/src/react/shallow.ts @@ -0,0 +1,13 @@ +import { useRef } from 'react' +import { shallow } from '../vanilla/shallow.ts' + +export function useShallow(selector: (state: S) => U): (state: S) => U { + const prev = useRef() + + return (state) => { + const next = selector(state) + return shallow(prev.current, next) + ? (prev.current as U) + : (prev.current = next) + } +} diff --git a/src/shallow.ts b/src/shallow.ts index f64c8ca80a..e9f4c4d15c 100644 --- a/src/shallow.ts +++ b/src/shallow.ts @@ -1,54 +1,8 @@ -import { useRef } from 'react' +import { shallow } from './vanilla/shallow.ts' -export function shallow(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(selector: (state: S) => U): (state: S) => U { - const prev = useRef() - - return (state) => { - const next = selector(state) - return shallow(prev.current, next) - ? (prev.current as U) - : (prev.current = next) - } -} +export { shallow } diff --git a/src/vanilla/shallow.ts b/src/vanilla/shallow.ts new file mode 100644 index 0000000000..7836a912a7 --- /dev/null +++ b/src/vanilla/shallow.ts @@ -0,0 +1,49 @@ +export function shallow(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 +} diff --git a/tests/shallow.test.tsx b/tests/shallow.test.tsx index 512cbed710..536f714018 100644 --- a/tests/shallow.test.tsx +++ b/tests/shallow.test.tsx @@ -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(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[] }]>() diff --git a/tests/vanilla/basic.test.ts b/tests/vanilla/basic.test.ts new file mode 100644 index 0000000000..22d71186cc --- /dev/null +++ b/tests/vanilla/basic.test.ts @@ -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((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((_, 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['setState'] + setState2: StoreApi['setState'] + } + + const store = createStore((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(() => 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(() => 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) +}) diff --git a/tests/vanilla/shallow.test.tsx b/tests/vanilla/shallow.test.tsx new file mode 100644 index 0000000000..169c725e12 --- /dev/null +++ b/tests/vanilla/shallow.test.tsx @@ -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(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) + }) +})