zustand/tests/shallow.test.tsx
Fabrizio Vitale 3cbd468fe5
feat: add useShallow (#2090)
* feat: add useShallow

See
- https://github.com/pmndrs/zustand/discussions/1937
- https://github.com/pmndrs/zustand/discussions/1937#discussioncomment-7118242
- https://github.com/pmndrs/zustand/discussions/1937#discussioncomment-6974554

* chore(useShallow): improve unit tests

* chore(useShallow): PR feedback https://github.com/pmndrs/zustand/pull/2090#discussion_r1341963105

* fix(useShallow): tests not working on test_matrix (cjs, production, CI-MATRIX-NOSKIP)

* chore(useShallow): fix eslint warning issue (unused import)

* refactor(useShallow): simplify tests

* docs(useShallow): add guide

* fix(useShallow): prettier:ci error https://github.com/pmndrs/zustand/actions/runs/6369420511/job/17289749161?pr=2090

* docs(useShallow): update readme

* docs(useShallow): remove obsolete line from readme

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

* doc(useShallow): PR feedback https://github.com/pmndrs/zustand/pull/2090#discussion_r1342120701

* docs(useShallow): small improvements of the useShallow guide

---------

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2023-10-02 22:13:13 +09:00

289 lines
7.7 KiB
TypeScript

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)
})
})
describe('types', () => {
it('works with useBoundStore and array selector (#1107)', () => {
const useBoundStore = create(() => ({
villages: [] as { name: string }[],
}))
const Component = () => {
const villages = useBoundStore((state) => state.villages, shallow)
return <>{villages.length}</>
}
expect(Component).toBeDefined()
})
it('works with useBoundStore and string selector (#1107)', () => {
const useBoundStore = create(() => ({
refetchTimestamp: '',
}))
const Component = () => {
const refetchTimestamp = useBoundStore(
(state) => state.refetchTimestamp,
shallow
)
return <>{refetchTimestamp.toUpperCase()}</>
}
expect(Component).toBeDefined()
})
})
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[] }]>()
const TestUseShallowSimple = ({
selector,
state,
}: {
state: Record<string, unknown>
selector: (state: Record<string, unknown>) => string[]
}) => {
const selectorOutput = selector(state)
const useShallowOutput = useShallow(selector)(state)
return (
<div
data-testid="test-shallow"
onClick={() =>
testUseShallowSimpleCallback({ selectorOutput, useShallowOutput })
}
/>
)
}
beforeEach(() => {
testUseShallowSimpleCallback.mockClear()
})
it('input and output selectors always return shallow equal values', () => {
const res = render(
<TestUseShallowSimple state={{ a: 1, b: 2 }} selector={Object.keys} />
)
expect(testUseShallowSimpleCallback).toHaveBeenCalledTimes(0)
fireEvent.click(res.getByTestId('test-shallow'))
const firstRender = testUseShallowSimpleCallback.mock.lastCall?.[0]
expect(testUseShallowSimpleCallback).toHaveBeenCalledTimes(1)
expect(firstRender).toBeTruthy()
expect(firstRender?.selectorOutput).toEqual(firstRender?.useShallowOutput)
res.rerender(
<TestUseShallowSimple
state={{ a: 1, b: 2, c: 3 }}
selector={Object.keys}
/>
)
fireEvent.click(res.getByTestId('test-shallow'))
expect(testUseShallowSimpleCallback).toHaveBeenCalledTimes(2)
const secondRender = testUseShallowSimpleCallback.mock.lastCall?.[0]
expect(secondRender).toBeTruthy()
expect(secondRender?.selectorOutput).toEqual(secondRender?.useShallowOutput)
})
it('returns the previously computed instance when possible', () => {
const state = { a: 1, b: 2 }
const res = render(
<TestUseShallowSimple state={state} selector={Object.keys} />
)
fireEvent.click(res.getByTestId('test-shallow'))
expect(testUseShallowSimpleCallback).toHaveBeenCalledTimes(1)
const output1 =
testUseShallowSimpleCallback.mock.lastCall?.[0]?.useShallowOutput
expect(output1).toBeTruthy()
// Change selector, same output
res.rerender(
<TestUseShallowSimple
state={state}
selector={(state) => Object.keys(state)}
/>
)
fireEvent.click(res.getByTestId('test-shallow'))
expect(testUseShallowSimpleCallback).toHaveBeenCalledTimes(2)
const output2 =
testUseShallowSimpleCallback.mock.lastCall?.[0]?.useShallowOutput
expect(output2).toBeTruthy()
expect(output2).toBe(output1)
})
it('only re-renders if selector output has changed according to shallow', () => {
let countRenders = 0
const useMyStore = create(
(): Record<string, unknown> => ({ a: 1, b: 2, c: 3 })
)
const TestShallow = ({
selector = (state) => Object.keys(state).sort(),
}: {
selector?: (state: Record<string, unknown>) => string[]
}) => {
const output = useMyStore(useShallow(selector))
++countRenders
return <div data-testid="test-shallow">{output.join(',')}</div>
}
expect(countRenders).toBe(0)
const res = render(<TestShallow />)
expect(countRenders).toBe(1)
expect(res.getByTestId('test-shallow').textContent).toBe('a,b,c')
act(() => {
useMyStore.setState({ a: 4 }) // This will not cause a re-render.
})
expect(countRenders).toBe(1)
act(() => {
useMyStore.setState({ d: 10 }) // This will cause a re-render.
})
expect(countRenders).toBe(2)
expect(res.getByTestId('test-shallow').textContent).toBe('a,b,c,d')
})
it('does not cause stale closure issues', () => {
const useMyStore = create(
(): Record<string, unknown> => ({ a: 1, b: 2, c: 3 })
)
const TestShallowWithState = () => {
const [count, setCount] = useState(0)
const output = useMyStore(
useShallow((state) => Object.keys(state).concat([count.toString()]))
)
return (
<div
data-testid="test-shallow"
onClick={() => setCount((prev) => ++prev)}>
{output.join(',')}
</div>
)
}
const res = render(<TestShallowWithState />)
expect(res.getByTestId('test-shallow').textContent).toBe('a,b,c,0')
fireEvent.click(res.getByTestId('test-shallow'))
expect(res.getByTestId('test-shallow').textContent).toBe('a,b,c,1')
})
})