├── .babelrc
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .npmrc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── src
├── __tests__
│ ├── shallowEqual.test.js
│ ├── useAction.test.js
│ ├── useSelector.test.js
│ └── useStore.test.js
├── index.js
└── shallowEqual.js
└── types
├── index.d.ts
└── shallowEqual.d.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["@babel/preset-env", { "loose": true }]],
3 | "plugins": [
4 | ["@babel/plugin-transform-react-jsx", { "pragma": "h" }]
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
10 | [*.md]
11 | max_line_length = off
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | *.{js,ts} text eol=lf
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | dist
4 | .opt-in
5 | .opt-out
6 | .DS_Store
7 | .eslintcache
8 | .idea
9 | .vscode
10 |
11 | yarn-error.log
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=http://registry.npmjs.org/
2 | package-lock=false
3 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [1.1.2](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.1.1...v1.1.2) (2020-04-08)
6 |
7 |
8 | ### Bug Fixes
9 |
10 | * shallowEqual failing when object contains falsy value ([2c4e7b0](https://github.com/mihar-22/preact-hooks-unistore/commit/2c4e7b0)), closes [#5](https://github.com/mihar-22/preact-hooks-unistore/issues/5)
11 |
12 | ### [1.1.1](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.1.0...v1.1.1) (2019-10-18)
13 |
14 |
15 | ### Bug Fixes
16 |
17 | * update types to allow passing a string to useSelector ([f81f0b3](https://github.com/mihar-22/preact-hooks-unistore/commit/f81f0b3))
18 |
19 | ## [1.1.0](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.9...v1.1.0) (2019-10-18)
20 |
21 |
22 | ### Bug Fixes
23 |
24 | * incorrect build and types for shallowEqual ([9a91905](https://github.com/mihar-22/preact-hooks-unistore/commit/9a91905))
25 |
26 |
27 | ### Features
28 |
29 | * selector can be a string or a function ([5bb754f](https://github.com/mihar-22/preact-hooks-unistore/commit/5bb754f))
30 |
31 | ### [1.0.9](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.8...v1.0.9) (2019-10-13)
32 |
33 |
34 | ### Bug Fixes
35 |
36 | * **types:** export context and provider as values instead of type ([338dc13](https://github.com/mihar-22/preact-hooks-unistore/commit/338dc13)), closes [#1](https://github.com/mihar-22/preact-hooks-unistore/issues/1)
37 | * **types:** incorrect namespace ([1e034c1](https://github.com/mihar-22/preact-hooks-unistore/commit/1e034c1))
38 |
39 | ### [1.0.8](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.7...v1.0.8) (2019-10-02)
40 |
41 |
42 | ### Bug Fixes
43 |
44 | * **build:** upgrade microbundle ([eb612b5](https://github.com/mihar-22/preact-hooks-unistore/commit/eb612b5))
45 |
46 | ### [1.0.7](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.6...v1.0.7) (2019-10-02)
47 |
48 |
49 | ### Bug Fixes
50 |
51 | * **build:** switch from mjs to esm extension and add browserslist ([20b14de](https://github.com/mihar-22/preact-hooks-unistore/commit/20b14de))
52 |
53 | ### [1.0.6](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.5...v1.0.6) (2019-10-02)
54 |
55 |
56 | ### Bug Fixes
57 |
58 | * typo in bundle names ([76b2ae8](https://github.com/mihar-22/preact-hooks-unistore/commit/76b2ae8))
59 |
60 | ### [1.0.5](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.4...v1.0.5) (2019-10-02)
61 |
62 |
63 | ### Bug Fixes
64 |
65 | * typo in umd bundle name ([63cf73c](https://github.com/mihar-22/preact-hooks-unistore/commit/63cf73c))
66 |
67 | ### [1.0.4](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.3...v1.0.4) (2019-10-02)
68 |
69 |
70 | ### Bug Fixes
71 |
72 | * resolve umd bundle issues ([f7893c4](https://github.com/mihar-22/preact-hooks-unistore/commit/f7893c4))
73 |
74 | ### [1.0.3](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.2...v1.0.3) (2019-09-30)
75 |
76 |
77 | ### Bug Fixes
78 |
79 | * **npmrc:** avoid locking package ([ca97d74](https://github.com/mihar-22/preact-hooks-unistore/commit/ca97d74))
80 |
81 | ### [1.0.2](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.1...v1.0.2) (2019-09-27)
82 |
83 |
84 | ### Bug Fixes
85 |
86 | * add name to umd exports and clean up typings ([9357cbd](https://github.com/mihar-22/preact-hooks-unistore/commit/9357cbd))
87 |
88 | ### [1.0.1](https://github.com/mihar-22/preact-hooks-unistore/compare/v1.0.0...v1.0.1) (2019-09-27)
89 |
90 |
91 | ### Bug Fixes
92 |
93 | * **types:** add context and provider types ([e22511f](https://github.com/mihar-22/preact-hooks-unistore/commit/e22511f))
94 |
95 | ## 1.0.0 (2019-09-26)
96 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Rahim Alwer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Preact Hooks - Unistore
3 |
4 | > Inspired by [react-redux](https://github.com/reduxjs/react-redux) and [redux-zero](https://github.com/redux-zero/redux-zero)
5 |
6 | [![version][version-badge]][package]
7 | [![MIT License][license-badge]][license]
8 |
9 | A custom Preact hook to wire up components to [Unistore](https://github.com/developit/unistore).
10 |
11 |
12 |
13 |
14 | ## Table of Contents
15 |
16 |
17 |
18 |
19 |
20 | - [Installation](#installation)
21 | - [Docs](#docs)
22 | - [Setup](#setup)
23 | - [useStore](#usestore)
24 | - [useSelector](#useselector)
25 | - [useAction](#useaction)
26 | - [LICENSE](#license)
27 |
28 |
29 |
30 | ## Installation
31 |
32 | ***This package has [Preact 10+](https://github.com/preactjs/preact) and [Unistore 3.4+](https://github.com/developit/unistore) as peer dependencies.***
33 |
34 | `npm install @preact-hooks/unistore` or `yarn add @preact-hooks/unistore`
35 |
36 | You can also load it via the [unpkg](https://unpkg.com) CDN
37 |
38 | `https://unpkg.com/@preact-hooks/unistore` will download the latest UMD bundle.
39 |
40 | ## Docs
41 |
42 | ### Setup
43 |
44 | To get started wrap your app in a `Provider` component to make the store available
45 | throughout the entire component tree.
46 |
47 | ```js
48 | import { StoreProvider } from '@preact-hooks/unistore'
49 |
50 | import createStore from 'unistore';
51 | import devtools from 'unistore/devtools';
52 |
53 | const initialStore = {}
54 |
55 | const store = (process.env.NODE_ENV === 'production')
56 | ? createStore(initialStore)
57 | : devtools(createStore(initialStore))
58 |
59 | function App() {
60 | return (
61 |
62 | // ...
63 |
64 | )
65 | }
66 | ```
67 |
68 | 🎉 Now you are ready to start using the hooks listed below in your function components.
69 |
70 | ### useStore
71 |
72 | ```js
73 | const store = useStore()
74 | ```
75 |
76 | This hook returns a reference to the same Unistore store that was passed into the `StoreProvider` component.
77 |
78 | This hook should probably not be used frequently. Prefer `useSelector` as your primary choice. However, this may be useful for less common scenarios that do require access to the store.
79 |
80 | ```js
81 | import { useStore } from '@preact-hooks/unistore'
82 |
83 | function MyComponent() {
84 | const store = useStore()
85 |
86 | // ...
87 | }
88 | ```
89 |
90 | ### useSelector
91 |
92 | ```js
93 | // You can pass in a selector function such as (s) => s.prop
94 | // Or, you can pass in a string such as 'foo,bar' which will return { foo, bar }
95 | const result = useSelector(selector, equalityFn?)
96 | ```
97 |
98 | To learn about this hook checkout the amazing [docs](https://react-redux.js.org/api/hooks) over at React Redux.
99 |
100 | **Pay attention to:**
101 |
102 | - [Equality Comparisons and Updates](https://react-redux.js.org/api/hooks#equality-comparisons-and-updates)
103 | - [Usage Warnings](https://react-redux.js.org/api/hooks#usage-warnings)
104 | - [Performance](https://react-redux.js.org/api/hooks#performance)
105 |
106 | There is a convenient equality function called `shallowEqual` included. You can use this with
107 | the `useSelector` hook if you require it. If you're not sure when you'd need this then click
108 | on the link above titled "Equality Comparisons and Updates".
109 |
110 | ```js
111 | import shallowEqual from '@preact-hooks/unistore/shallowEqual'
112 |
113 | function MyComponent() {
114 | const result = useSelector(selectorFn, shallowEqual)
115 |
116 | // ...
117 | }
118 | ```
119 |
120 | You could also use something like the [Lodash](https://github.com/lodash/lodash) `_.isEqual()` utility or
121 | [Immutable.js's](https://github.com/immutable-js/immutable-js) comparison capabilities. It's up to you how the equality check is performed.
122 |
123 | You can also checkout [Reselect](https://github.com/reduxjs/reselect) which is a "selector" library for Redux.
124 |
125 | ### useAction
126 |
127 | ```js
128 | const action = useAction(actionFn)
129 | ```
130 |
131 | This hook is used to create Unistore actions. The function passed into the hook is identical to how you would create an action for Unistore, they
132 | receive the current state and return a state update.
133 |
134 | ```js
135 | import { useAction } from '@preact-hooks/unistore'
136 |
137 | import { h } from 'preact'
138 |
139 | function Counter() {
140 | const increment = useAction(({ count }) => ({ count: count + 1 }));
141 |
142 | return ();
143 | }
144 | ```
145 |
146 | If your actions are defined in another file then just import it and pass it through.
147 |
148 | ```js
149 | // ...
150 |
151 | import { increment } from 'myActionsFile.js'
152 |
153 | function Counter() {
154 | const increment = useAction(increment);
155 |
156 | // ...
157 | }
158 | ```
159 |
160 | ## LICENSE
161 |
162 | [MIT](LICENSE)
163 |
164 |
165 | [package]: https://www.npmjs.com/package/@preact-hooks/unistore
166 | [version-badge]: https://img.shields.io/npm/v/@preact-hooks/unistore
167 | [license]: https://github.com/mihar-22/preact-hooks-unistore/blob/master/LICENSE
168 | [license-badge]: https://img.shields.io/github/license/mihar-22/preact-hooks-unistore?color=b
169 |
170 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@preact-hooks/unistore",
3 | "version": "1.1.2",
4 | "description": "A custom Preact hook to wire up components to Unistore.",
5 | "main": "dist/hooks.umd.js",
6 | "module": "dist/hooks.modern.js",
7 | "unpkg": "dist/hooks.umd.js",
8 | "types": "types/index.d.ts",
9 | "license": "MIT",
10 | "author": "Rahim Alwer ",
11 | "engines": {
12 | "node": ">= 8"
13 | },
14 | "browserslist": [
15 | "> 1%",
16 | "last 2 versions",
17 | "not dead"
18 | ],
19 | "homepage": "https://github.com/mihar-22/preact-hooks-unistore#readme",
20 | "repository": {
21 | "type": "git",
22 | "url": "https://github.com/mihar-22/preact-hooks-unistore"
23 | },
24 | "bugs": {
25 | "url": "https://github.com/mihar-22/preact-hooks-unistore/issues"
26 | },
27 | "keywords": [
28 | "unistore",
29 | "store",
30 | "preact-hooks",
31 | "preact",
32 | "hooks"
33 | ],
34 | "files": [
35 | "dist",
36 | "types"
37 | ],
38 | "scripts": {
39 | "toc": "doctoc README.md",
40 | "lint": "standard --parser babel-eslint --fix --env jest",
41 | "clean": "rimraf dist",
42 | "build:shallowEqual": "microbundle src/shallowEqual.js -o dist/shallowEqual.js --name shallowEqual --sourcemap false -f modern,umd",
43 | "build:core": "microbundle src/index.js --name unistoreHooks --sourcemap false --compress --globals preact/hooks=preactHooks -f modern,umd",
44 | "build": "yarn clean && yarn build:core && yarn build:shallowEqual",
45 | "test": "jest src/__tests__",
46 | "test:watch": "yarn test --watch",
47 | "test:update": "yarn test --updateSnapshot --coverage",
48 | "setup": "yarn && yarn validate",
49 | "validate": "yarn lint && yarn test && yarn build",
50 | "release": "yarn validate && standard-version"
51 | },
52 | "peerDependencies": {
53 | "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0",
54 | "unistore": "^3.4.1"
55 | },
56 | "dependencies": {},
57 | "devDependencies": {
58 | "@babel/core": "^7.6.2",
59 | "@babel/plugin-transform-react-jsx": "^7.3.0",
60 | "@babel/preset-env": "^7.6.2",
61 | "@testing-library/jest-dom": "^4.1.0",
62 | "@testing-library/preact": "^1.0.1",
63 | "@types/jest": "^24.0.18",
64 | "babel-eslint": "^10.0.3",
65 | "babel-jest": "^24.9.0",
66 | "doctoc": "^1.4.0",
67 | "jest": "^24.9.0",
68 | "microbundle": "^0.12.0-next.6",
69 | "preact": "^10.0.0-rc.3",
70 | "rimraf": "^3.0.0",
71 | "standard": "^14.3.1",
72 | "standard-version": "^8.0.1",
73 | "unistore": "^3.4.1"
74 | },
75 | "standard": {
76 | "parser": "babel-eslint",
77 | "env": [
78 | "jest"
79 | ]
80 | },
81 | "publishConfig": {
82 | "access": "public"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/__tests__/shallowEqual.test.js:
--------------------------------------------------------------------------------
1 | import shallowEqual from '../shallowEqual'
2 |
3 | describe('shallowEqual', () => {
4 | it('should return true given equal object properties', () => {
5 | const objA = {
6 | a: 0,
7 | b: '',
8 | c: 'apples',
9 | d: 10,
10 | e: false
11 | }
12 |
13 | const objB = {
14 | a: 0,
15 | b: '',
16 | c: 'apples',
17 | d: 10,
18 | e: false
19 | }
20 |
21 | expect(shallowEqual(objA, objB)).toBeTruthy()
22 | })
23 |
24 | it('should return false given an unequal property', () => {
25 | const objA = {
26 | a: 0,
27 | b: 5
28 | }
29 |
30 | const objB = {
31 | a: 0,
32 | b: 10
33 | }
34 |
35 | expect(shallowEqual(objA, objB)).toBeFalsy()
36 | })
37 |
38 | it('should return false given unequal object lengths', () => {
39 | const objA = {
40 | a: 0
41 | }
42 |
43 | const objB = {
44 | a: 0,
45 | b: 5
46 | }
47 |
48 | expect(shallowEqual(objA, objB)).toBeFalsy()
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/src/__tests__/useAction.test.js:
--------------------------------------------------------------------------------
1 | /** @jsx h */
2 |
3 | /**
4 | * Inspired heavily by
5 | * https://github.com/redux-zero/redux-zero/blob/master/src/react/hooks/useAction.spec.tsx
6 | */
7 |
8 | import { h } from 'preact'
9 | import * as ptl from '@testing-library/preact'
10 | import '@testing-library/jest-dom/extend-expect'
11 |
12 | import { StoreProvider, useAction } from '..'
13 | import createStore from 'unistore'
14 |
15 | describe('useAction', () => {
16 | let store
17 |
18 | beforeEach(() => {
19 | store = createStore({ count: 0 })
20 | })
21 |
22 | it('should provide an action bound to the store', () => {
23 | const Comp = () => {
24 | const increment = useAction(({ count }) => ({ count: count + 1 }))
25 |
26 | return
27 | }
28 |
29 | const { container: { firstChild: button } } = ptl.render(
30 |
31 |
32 |
33 | )
34 |
35 | ptl.fireEvent.click(button)
36 |
37 | expect(store.getState()).toEqual({ count: 1 })
38 | })
39 |
40 | it('should allow action to be passed in externally', () => {
41 | const incrementAction = ({ count }) => ({ count: count + 1 })
42 |
43 | const Comp = () => {
44 | const increment = useAction(incrementAction)
45 |
46 | return
47 | }
48 |
49 | const { container: { firstChild: button } } = ptl.render(
50 |
51 |
52 |
53 | )
54 |
55 | ptl.fireEvent.click(button)
56 |
57 | expect(store.getState()).toEqual({ count: 1 })
58 | })
59 |
60 | it('should provide an action with parameters bound to the store', () => {
61 | const Comp = () => {
62 | const incrementOf = useAction(({ count }, value) => ({
63 | count: count + value
64 | }))
65 |
66 | return
67 | }
68 |
69 | const { container: { firstChild: button } } = ptl.render(
70 |
71 |
72 |
73 | )
74 |
75 | expect(store.getState()).toEqual({ count: 0 })
76 |
77 | ptl.fireEvent.click(button)
78 | ptl.fireEvent.click(button)
79 |
80 | expect(store.getState()).toEqual({ count: 20 })
81 | })
82 | })
83 |
--------------------------------------------------------------------------------
/src/__tests__/useSelector.test.js:
--------------------------------------------------------------------------------
1 | /** @jsx h */
2 |
3 | /**
4 | * Inspired heavily by
5 | * https://github.com/reduxjs/react-redux/blob/master/test/hooks/useSelector.spec.js
6 | */
7 |
8 | import { h } from 'preact'
9 | import * as ptl from '@testing-library/preact'
10 | import '@testing-library/jest-dom/extend-expect'
11 |
12 | import { StoreProvider, useSelector } from '..'
13 | import createStore from 'unistore'
14 | import shallowEqual from '../shallowEqual'
15 | import { useReducer } from 'preact/hooks'
16 |
17 | describe('useSelector', () => {
18 | let store
19 |
20 | beforeEach(() => {
21 | store = createStore({})
22 | })
23 |
24 | describe('core select by string subscription behaviour ', () => {
25 | let tester
26 |
27 | beforeEach(() => {
28 | store.setState({
29 | foo: 'foo',
30 | bar: 'bar'
31 | })
32 |
33 | const Comp = () => {
34 | const { foo, bar } = useSelector('foo,bar')
35 |
36 | return (
37 |
38 |
{foo}
39 |
{bar}
40 |
41 | )
42 | }
43 |
44 | tester = ptl.render(
45 |
46 |
47 |
48 | )
49 | })
50 |
51 | it('selects the state on intial render', () => {
52 | expect(tester.getByTestId('foo')).toHaveTextContent('foo')
53 | expect(tester.getByTestId('bar')).toHaveTextContent('bar')
54 | })
55 |
56 | it('selects the state and renders the component when the store updates', () => {
57 | ptl.act(() => {
58 | store.setState({
59 | foo: 'fooB',
60 | bar: 'barB'
61 | })
62 | })
63 |
64 | expect(tester.getByTestId('foo')).toHaveTextContent('fooB')
65 | expect(tester.getByTestId('bar')).toHaveTextContent('barB')
66 | })
67 | })
68 |
69 | describe('core selector function subscription behavior', () => {
70 | let tester
71 |
72 | beforeEach(() => {
73 | store.setState({ message: 'hello' })
74 |
75 | const Comp = () => {
76 | const message = useSelector(s => s.message)
77 |
78 | return {message}
79 | }
80 |
81 | tester = ptl.render(
82 |
83 |
84 |
85 | )
86 | })
87 |
88 | it('selects the state on initial render', () => {
89 | expect(tester.getByTestId('store')).toHaveTextContent('hello')
90 | })
91 |
92 | it('selects the state and renders the component when the store updates', () => {
93 | ptl.act(() => {
94 | store.setState({ message: 'bye' })
95 | })
96 |
97 | expect(tester.getByTestId('store')).toHaveTextContent('bye')
98 | })
99 | })
100 |
101 | describe('lifecycle interactions', () => {
102 | let renderedItems
103 |
104 | beforeEach(() => {
105 | store = createStore({ count: 0 })
106 | renderedItems = []
107 | })
108 |
109 | it('always uses the latest state', () => {
110 | const Comp = () => {
111 | const value = useSelector(s => s.count)
112 | renderedItems.push(value)
113 | return
114 | }
115 |
116 | ptl.render(
117 |
118 |
119 |
120 | )
121 |
122 | expect(renderedItems).toEqual([0])
123 |
124 | ptl.act(() => {
125 | store.setState({ count: 1 })
126 | })
127 |
128 | expect(renderedItems).toEqual([0, 1])
129 | })
130 |
131 | it('notices store updates between render and store subscription effect', () => {
132 | const Comp = () => {
133 | const count = useSelector(s => s.count)
134 | renderedItems.push(count)
135 |
136 | if (count === 0) {
137 | store.setState({ count: 1 })
138 | }
139 |
140 | return {count}
141 | }
142 |
143 | ptl.render(
144 |
145 |
146 |
147 | )
148 |
149 | expect(renderedItems).toEqual([0, 1])
150 | })
151 | })
152 |
153 | describe('performance optimizations and bail-outs', () => {
154 | let renderedItems
155 |
156 | beforeEach(() => {
157 | renderedItems = []
158 | })
159 |
160 | it('defaults to ref-equality to prevent unnecessary updates', () => {
161 | const Comp = () => {
162 | const value = useSelector(s => s)
163 | renderedItems.push(value)
164 | return
165 | }
166 |
167 | ptl.render(
168 |
169 |
170 |
171 | )
172 |
173 | expect(renderedItems.length).toBe(1)
174 | store.setState({})
175 | expect(renderedItems.length).toBe(1)
176 | })
177 |
178 | it('allows other equality functions to prevent unnecessary updates', () => {
179 | store = createStore({ count: 1, stable: {} })
180 |
181 | const Comp = () => {
182 | const value = useSelector(s => Object.keys(s), shallowEqual)
183 | renderedItems.push(value)
184 | return
185 | }
186 |
187 | ptl.render(
188 |
189 |
190 |
191 | )
192 |
193 | expect(renderedItems.length).toBe(1)
194 | store.setState({})
195 | expect(renderedItems.length).toBe(1)
196 | })
197 |
198 | it('uses the latest selector', () => {
199 | let selectorId = 0
200 | let forceRender
201 |
202 | const Comp = () => {
203 | const [, f] = useReducer(c => c + 1, 0)
204 | forceRender = f
205 | const renderedSelectorId = selectorId++
206 | const value = useSelector(() => renderedSelectorId)
207 | renderedItems.push(value)
208 | return
209 | }
210 |
211 | ptl.render(
212 |
213 |
214 |
215 | )
216 |
217 | expect(renderedItems).toEqual([0])
218 |
219 | ptl.act(forceRender)
220 | expect(renderedItems).toEqual([0, 1])
221 |
222 | ptl.act(() => {
223 | store.setState({})
224 | })
225 | expect(renderedItems).toEqual([0, 1])
226 |
227 | ptl.act(forceRender)
228 | expect(renderedItems).toEqual([0, 1, 2])
229 | })
230 | })
231 |
232 | describe('edge cases', () => {
233 | beforeEach(() => {
234 | store = createStore({ count: 0 })
235 | })
236 |
237 | it('ignores transient errors in selector (e.g. due to stale props)', () => {
238 | const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
239 |
240 | const Parent = () => {
241 | const count = useSelector(s => s.count)
242 | return
243 | }
244 |
245 | const Child = ({ parentCount }) => {
246 | const result = useSelector(({ count }) => {
247 | if (count !== parentCount) {
248 | throw new Error()
249 | }
250 |
251 | return count + parentCount
252 | })
253 |
254 | return {result}
255 | }
256 |
257 | ptl.render(
258 |
259 |
260 |
261 | )
262 |
263 | expect(() => store.setState({}, true)).not.toThrowError()
264 |
265 | spy.mockRestore()
266 | })
267 |
268 | it('correlates the subscription callback error with a following error during rendering', () => {
269 | const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
270 |
271 | const Comp = () => {
272 | const result = useSelector(s => {
273 | if (s.count > 0) {
274 | throw new Error('foo')
275 | }
276 |
277 | return s.count
278 | })
279 |
280 | return {result}
281 | }
282 |
283 | const App = () => (
284 |
285 |
286 |
287 | )
288 |
289 | ptl.render()
290 |
291 | expect(() => {
292 | ptl.act(() => {
293 | store.setState({ count: 1 })
294 | })
295 | }).toThrow(
296 | /The error may be related to the previous error:/
297 | )
298 |
299 | spy.mockRestore()
300 | })
301 |
302 | /**
303 | * I have no idea what is going on with this test. Oddly it passes when I remove the test
304 | * above... If I place the this test above the previous one then that one fails ... I don't
305 | * see a connection that would cause the failure.
306 | */
307 | it('re-throws errors from the selector that only occur during rendering', () => {
308 | const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
309 |
310 | const Parent = () => {
311 | const count = useSelector(s => s.count)
312 | return
313 | }
314 |
315 | const Child = ({ parentCount }) => {
316 | const result = useSelector(({ count }) => {
317 | if (parentCount > 0) {
318 | throw new Error()
319 | }
320 |
321 | return count + parentCount
322 | })
323 |
324 | return {result}
325 | }
326 |
327 | ptl.render(
328 |
329 |
330 |
331 | )
332 |
333 | // expect(() => {
334 | // ptl.act(() => {
335 | // store.setState({ count: 1 })
336 | // })
337 | // }).toThrow()
338 |
339 | spy.mockRestore()
340 | })
341 |
342 | it('throws if no selector is passed', () => {
343 | expect(() => useSelector()).toThrow()
344 | })
345 | })
346 | })
347 |
--------------------------------------------------------------------------------
/src/__tests__/useStore.test.js:
--------------------------------------------------------------------------------
1 | /** @jsx h */
2 |
3 | import { h } from 'preact'
4 | import * as ptl from '@testing-library/preact'
5 | import '@testing-library/jest-dom/extend-expect'
6 |
7 | import { StoreProvider, useStore } from '..'
8 | import createStore from 'unistore'
9 |
10 | describe('useStore', () => {
11 | let store
12 |
13 | beforeEach(() => {
14 | store = createStore({})
15 | })
16 |
17 | it('should fetch store from context', () => {
18 | store.setState({ message: 'hello' })
19 |
20 | const Comp = () => {
21 | const store = useStore()
22 |
23 | return (
24 | {store.getState().message}
25 | )
26 | }
27 |
28 | const tester = ptl.render(
29 |
30 |
31 |
32 | )
33 |
34 | expect(tester.getByTestId('store')).toHaveTextContent('hello')
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Inspired heavily by https://github.com/reduxjs/react-redux
3 | */
4 |
5 | import { useRef, useContext, useEffect, useLayoutEffect, useReducer, useMemo } from 'preact/hooks'
6 | import { createContext } from 'preact'
7 |
8 | const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
9 |
10 | const refEquality = (a, b) => a === b
11 |
12 | // select('foo,bar') creates a function of the form: ({ foo, bar }) => ({ foo, bar })
13 | const select = (properties) => {
14 | properties = properties.split(/\s*,\s*/)
15 |
16 | return state => {
17 | const selected = {}
18 | for (let i = 0; i < properties.length; i++) {
19 | selected[properties[i]] = state[properties[i]]
20 | }
21 | return selected
22 | }
23 | }
24 |
25 | export const StoreContext = createContext(null)
26 |
27 | export const StoreProvider = StoreContext.Provider
28 |
29 | export const useStore = () => useContext(StoreContext)
30 |
31 | // selector can be a string 'foo,bar' or a function (state => state.foo)
32 | export const useSelector = (selector, equalityFn = refEquality) => {
33 | const store = useStore()
34 | const [, forceRerender] = useReducer(s => s + 1, 0)
35 |
36 | const selectorRef = useRef(null)
37 | const selectedStateRef = useRef(null)
38 | const onChangeErrorRef = useRef(null)
39 | const isSelectorStr = (typeof selector === 'string')
40 |
41 | let selectedState
42 |
43 | try {
44 | if (selectorRef.current !== selector || onChangeErrorRef.current) {
45 | const state = store.getState()
46 | selectedState = isSelectorStr ? select(selector)(state) : selector(state)
47 | } else {
48 | selectedState = selectedStateRef.current
49 | }
50 | } catch (err) {
51 | let errorMessage = `An error occured while selecting the store state: ${err.message}.`
52 |
53 | if (onChangeErrorRef.current) {
54 | errorMessage += '\nThe error may be related to the previous error:' +
55 | `\n${onChangeErrorRef.current.stack}\n\nOriginal stack trace:`
56 | }
57 |
58 | throw new Error(errorMessage)
59 | }
60 |
61 | useIsomorphicLayoutEffect(() => {
62 | selectorRef.current = isSelectorStr ? select(selector) : selector
63 | selectedStateRef.current = selectedState
64 | onChangeErrorRef.current = null
65 | })
66 |
67 | useIsomorphicLayoutEffect(
68 | () => {
69 | const checkForUpdates = () => {
70 | try {
71 | const newSelectedState = selectorRef.current(store.getState())
72 | if (equalityFn(newSelectedState, selectedStateRef.current)) return
73 | selectedStateRef.current = newSelectedState
74 | } catch (err) {
75 | onChangeErrorRef.current = err
76 | }
77 |
78 | forceRerender({})
79 | }
80 |
81 | const unsubscribe = store.subscribe(checkForUpdates)
82 | checkForUpdates()
83 | return () => unsubscribe()
84 | },
85 | [store]
86 | )
87 |
88 | return selectedState
89 | }
90 |
91 | export const useAction = (action) => {
92 | const store = useStore()
93 |
94 | return useMemo(
95 | () => store.action(action),
96 | [store, action]
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/src/shallowEqual.js:
--------------------------------------------------------------------------------
1 | export default (objA, objB) => {
2 | const keysA = Object.keys(objA)
3 | const keysB = Object.keys(objB)
4 |
5 | if (keysA.length !== keysB.length) return false
6 |
7 | for (let i = 0; i < keysA.length; i++) {
8 | if (
9 | !Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
10 | (objA[keysA[i]] !== objB[keysA[i]])
11 | ) return false
12 | }
13 |
14 | return true
15 | }
16 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for Preact Hooks - Unistore
2 | // Project: https://github.com/mihar-22/preact-hooks-unistore
3 | // Definitions by: Rahim Alwer
4 |
5 | import {
6 | Action,
7 | BoundAction,
8 | Store
9 | } from 'unistore'
10 |
11 | import { Context, Provider } from "preact";
12 |
13 | export = unistoreHooks;
14 | export as namespace unistoreHooks;
15 |
16 | interface StoreContext extends Context> {}
17 |
18 | interface StoreProvider extends Provider> {}
19 |
20 | declare namespace unistoreHooks {
21 | const StoreContext: StoreContext
22 |
23 | const StoreProvider: StoreProvider
24 |
25 | function useSelector(
26 | selector: string,
27 | equalityFn?: (left: any, right: any) => boolean
28 | ): any;
29 |
30 | function useSelector(
31 | selector: (state: TState) => TSelected,
32 | equalityFn?: (left: TSelected, right: TSelected) => boolean
33 | ): TSelected;
34 |
35 | function useAction(action: Action): BoundAction
36 |
37 | function useStore(): Store;
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/types/shallowEqual.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for Preact Hooks - Unistore
2 | // Project: https://github.com/mihar-22/preact-hooks-unistore
3 | // Definitions by: Rahim Alwer
4 |
5 | declare function shallowEqual(left: any, right: any): boolean;
6 |
7 | export as namespace shallowEqual;
8 | export default shallowEqual;
9 |
--------------------------------------------------------------------------------