├── test ├── useForm.js ├── index.html ├── enhook.js ├── useCountdown.js ├── register.js ├── usePrevious.js ├── useThrottle.js ├── useObservable.js ├── index.js ├── useSyncEffect.js ├── useEffect.js ├── useCookie.js ├── useInput.js ├── useState.js ├── useAction.js ├── useValidate.js ├── useAttribute.js ├── useGlobalCache.js ├── useProperty.js ├── useStorage.js ├── useChannel.js ├── useStore.js ├── useSearchParam.js └── useFormField.js ├── .travis.yml ├── .gitattributes ├── LICENSE ├── .gitignore ├── package.json ├── index.js └── readme.md /test/useForm.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /test/enhook.js: -------------------------------------------------------------------------------- 1 | import enhook from 'enhook' 2 | import setHooks from '../' 3 | 4 | import * as preact from 'preact' 5 | import * as hooks from 'preact/hooks' 6 | enhook.use(preact) 7 | setHooks(hooks) 8 | 9 | // import * as atomico from 'atomico' 10 | // enhook.use(atomico) 11 | // setHooks(atomico) 12 | 13 | // import * as aug from 'augmentor' 14 | // enhook.use(aug) 15 | // setHooks(aug) 16 | 17 | 18 | export default enhook 19 | -------------------------------------------------------------------------------- /test/useCountdown.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useCountdown } from '../' 4 | import { time } from 'wait-please' 5 | 6 | t('useCountdown: basics', async t => { 7 | let log = [] 8 | let f = enhook(() => { 9 | let [n, reset] = useCountdown(3, 50) 10 | log.push(n) 11 | }) 12 | f() 13 | 14 | t.deepEqual(log, [ 3 ]) 15 | await time(300) 16 | t.deepEqual(log, [ 3, 2, 1, 0 ]) 17 | 18 | t.end() 19 | }) 20 | -------------------------------------------------------------------------------- /test/register.js: -------------------------------------------------------------------------------- 1 | let { JSDOM } = require('jsdom') 2 | 3 | const { window } = new JSDOM(``, { 4 | url: "http://localhost/", 5 | storageQuota: 10000000, 6 | pretendToBeVisual: true, 7 | FetchExternalResources: false, 8 | ProcessExternalResources: false 9 | }) 10 | 11 | let props = Object.getOwnPropertyNames(window) 12 | 13 | props.forEach(prop => { 14 | if (prop in global) return 15 | Object.defineProperty(global, prop, { 16 | configurable: true, 17 | get: () => window[prop] 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/usePrevious.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { usePrevious, useState } from '../' 4 | import { tick } from 'wait-please' 5 | 6 | t('usePrevious: basics', async t => { 7 | let log = [] 8 | let f = enhook(() => { 9 | let [foo, setFoo] = useState(1) 10 | let fooPrev = usePrevious(foo) 11 | log.push(foo, fooPrev) 12 | }) 13 | f() 14 | 15 | t.deepEqual(log, [ 1, undefined ]) 16 | 17 | await tick() 18 | f() 19 | t.deepEqual(log, [ 1, undefined, 1, 1 ]) 20 | 21 | t.end() 22 | }) 23 | -------------------------------------------------------------------------------- /test/useThrottle.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useThrottle } from '../src/index' 4 | import { time } from 'wait-please' 5 | 6 | t.skip('useThrottle: basics', async t => { 7 | let log = [] 8 | let f = enhook((x) => { 9 | let [v] = useThrottle(x, 50) 10 | log.push(v) 11 | }) 12 | f(1) 13 | f(2) 14 | t.deepEqual(log, [ 1 ]) 15 | await time(10) 16 | t.deepEqual(log, [ 1 ]) 17 | await time(40) 18 | t.deepEqual(log, [ 1, 2 ]) 19 | await time(50) 20 | t.deepEqual(log, [ 1, 2 ]) 21 | 22 | t.end() 23 | }) 24 | -------------------------------------------------------------------------------- /test/useObservable.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { tick, frame, time } from 'wait-please' 4 | import { value } from 'observable' 5 | import { useObservable } from '../index.js' 6 | 7 | t('useObservable: core', async t => { 8 | let log = [] 9 | let v = value(0) 10 | let f = enhook(() => { 11 | let [val, setVal] = useObservable(v) 12 | log.push(val) 13 | setTimeout(() => setVal(1)) 14 | }) 15 | 16 | f() 17 | await frame() 18 | t.is(log, [0]) 19 | 20 | await time(15) 21 | t.is(log, [0, 1]) 22 | t.is(v(), 1) 23 | 24 | t.end() 25 | }) 26 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import('./useChannel').then() 2 | import('./useAction').then() 3 | import('./useStorage').then() 4 | import('./useSearchParam').then() 5 | import('./useCountdown').then() 6 | import('./usePrevious').then() 7 | import('./useFormField').then() 8 | import('./useInput').then() 9 | import('./useValidate').then() 10 | import('./useObservable').then() 11 | // import('./useStore').then() 12 | // import('./useGlobalCache').then() 13 | // import('./useLocalStorage').then() 14 | // import('./useProperty').then() 15 | // import('./useState').then() 16 | // import('./useEffect').then() 17 | // import('./useSyncEffect').then() 18 | // import('./useCookie').then() 19 | // import('./useThrottle').then() 20 | // import('./useAttribute').then() 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 dy 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 | -------------------------------------------------------------------------------- /test/useSyncEffect.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useSyncEffect } from '../src/index' 4 | import { tick, frame } from 'wait-please' 5 | 6 | t('useSyncEffect: useInit', t => { 7 | let log = [] 8 | 9 | let f = enhook((i) => { 10 | useSyncEffect(() => { 11 | log.push('on') 12 | return () => log.push('off') 13 | }, []) 14 | }) 15 | 16 | f(1) 17 | t.deepEqual(log, ['on']) 18 | f(2) 19 | t.deepEqual(log, ['on']) 20 | 21 | t.end() 22 | }) 23 | 24 | t('useSyncEffect: destructor is fine ', t => { 25 | let log = [] 26 | 27 | let f = enhook((i) => { 28 | useSyncEffect(() => { 29 | log.push('on') 30 | return () => log.push('off') 31 | }, [i]) 32 | }) 33 | 34 | f(1) 35 | t.deepEqual(log, ['on']) 36 | f(2) 37 | t.deepEqual(log, ['on', 'off', 'on']) 38 | 39 | t.end() 40 | }) 41 | 42 | t('useSyncEffect: async fn is fine', async t => { 43 | let log = [] 44 | 45 | let f = enhook((i) => { 46 | useSyncEffect(async () => { 47 | await tick(2) 48 | log.push(1) 49 | }, []) 50 | }) 51 | 52 | f() 53 | await tick(3) 54 | t.deepEqual(log, [1]) 55 | f() 56 | await tick(3) 57 | t.deepEqual(log, [1]) 58 | 59 | t.end() 60 | }) 61 | -------------------------------------------------------------------------------- /test/useEffect.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useEffect } from '../src/index' 4 | import { tick, frame } from 'wait-please' 5 | 6 | t('useEffect: microtask guaranteed', async t => { 7 | let log = [] 8 | 9 | let f = (i) => { 10 | log.push('call', i) 11 | useEffect(() => { 12 | log.push('effect', i) 13 | return 1 14 | }) 15 | } 16 | 17 | let f1 = enhook(f) 18 | let f2 = enhook(f) 19 | f1(1) 20 | f2(2) 21 | await tick(2) 22 | t.deepEqual(log, ['call', 1, 'call', 2, 'effect', 1, 'effect', 2]) 23 | 24 | t.end() 25 | }) 26 | 27 | t('useEffect: async fn is fine', async t => { 28 | let log = [] 29 | 30 | let f = enhook((i) => { 31 | useEffect(async () => { 32 | await tick(2) 33 | log.push(1) 34 | }, []) 35 | }) 36 | 37 | f() 38 | await tick(3) 39 | t.deepEqual(log, [1]) 40 | f() 41 | await tick(3) 42 | t.deepEqual(log, [1]) 43 | 44 | t.end() 45 | }) 46 | 47 | t('useEffect: dispose should clean up effects', async t => { 48 | let log = [] 49 | let f = enhook(() => { 50 | useEffect(() => { 51 | log.push('in') 52 | return () => log.push('out') 53 | }) 54 | }) 55 | f() 56 | await frame(2) 57 | f.unhook() 58 | await frame(2) 59 | t.deepEqual(log, ['in', 'out']) 60 | 61 | t.end() 62 | }) 63 | -------------------------------------------------------------------------------- /test/useCookie.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useCookie, useEffect } from '../src/index' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame } from 'wait-please' 5 | import * as cookie from 'es-cookie' 6 | 7 | 8 | t('useCookie: basic', async t => { 9 | cookie.set('foo', 'bar') 10 | let log = [] 11 | let f = enhook(() => { 12 | let [foo, setFoo] = useCookie('foo') 13 | log.push(foo) 14 | useEffect(() => { 15 | setFoo('baz') 16 | }, []) 17 | }) 18 | f() 19 | t.deepEqual(log, ['bar']) 20 | await frame(4) 21 | t.deepEqual(log, ['bar', 'baz']) 22 | 23 | cookie.remove('foo') 24 | t.end() 25 | }) 26 | 27 | t('useCookie: multiple components use same key', async t => { 28 | cookie.set('count', 1) 29 | let log = [] 30 | 31 | const f = (i, log) => { 32 | let [count, setCount] = useCookie('count', i) 33 | log.push('call', i, count) 34 | useEffect(() => { 35 | log.push('effect', i) 36 | setCount(i) 37 | }, []) 38 | } 39 | let f1 = enhook(f) 40 | let f2 = enhook(f) 41 | 42 | f1(1, log) 43 | t.deepEqual(log, ['call', 1, 1]) 44 | await frame(2) 45 | t.deepEqual(log, ['call', 1, 1, 'effect', 1]) 46 | 47 | f2(2, log) 48 | await frame(4) 49 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2]) 50 | 51 | cookie.remove('count') 52 | t.end() 53 | }) 54 | -------------------------------------------------------------------------------- /test/useInput.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useInput, useEffect, useRef } from '../' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame, time } from 'wait-please' 5 | import { render } from 'preact' 6 | import { html } from 'htm/preact' 7 | 8 | 9 | t('useInput: element', async t => { 10 | let log = [] 11 | let input = document.createElement('input') 12 | input.value = 'foo' 13 | 14 | let f = enhook(() => { 15 | let [v, setV] = useInput(input) 16 | log.push(v) 17 | useEffect(() => { 18 | setV('bar') 19 | }, []) 20 | }) 21 | f() 22 | 23 | t.deepEqual(log, ['foo']) 24 | 25 | await frame(2) 26 | 27 | t.deepEqual(log, ['foo', 'bar']) 28 | t.equal(input.value, 'bar') 29 | 30 | input.value = 'baz' 31 | input.dispatchEvent(new Event('change')) 32 | await frame(2) 33 | t.deepEqual(log, ['foo', 'bar', 'baz']) 34 | 35 | f.unhook() 36 | t.end() 37 | }) 38 | 39 | 40 | t('useInput: ref', async t => { 41 | let log = [] 42 | let el = document.createElement('div') 43 | 44 | render(html`<${() => { 45 | let ref = useRef() 46 | let [v, setV] = useInput(ref) 47 | log.push(v) 48 | useEffect(() => { 49 | setV('foo') 50 | 51 | setTimeout(() => { 52 | setV(null) 53 | }, 50) 54 | }, []) 55 | 56 | return html`` 57 | }}/>`, el) 58 | 59 | let input = el.querySelector('input') 60 | await frame(2) 61 | t.equal(input.value, 'foo') 62 | t.deepEqual(log, [undefined, 'foo']) 63 | await time(50) 64 | t.ok(!input.hasAttribute('value')) 65 | 66 | render(null, el) 67 | 68 | t.end() 69 | }) 70 | -------------------------------------------------------------------------------- /test/useState.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useState, useEffect, useCallback } from '../src/standard' 4 | import { tick, frame, time } from 'wait-please' 5 | 6 | t('useState: initial run once', async t => { 7 | let log = [] 8 | 9 | let f = enhook(() => { 10 | let [v, setV] = useState((prev) => { 11 | log.push(prev) 12 | return 1 13 | }) 14 | }) 15 | 16 | f() 17 | t.deepEqual(log, [undefined]) 18 | 19 | f() 20 | t.deepEqual(log, [undefined]) 21 | 22 | t.end() 23 | }) 24 | 25 | t('useState: unchanged value does not cause rerender', async t => { 26 | let log = [] 27 | let x = 0 28 | let f = enhook(() => { 29 | let [v, setV] = useState(0) 30 | log.push(v) 31 | useEffect(() => { 32 | setV(() => x) 33 | }, [x]) 34 | }) 35 | f() 36 | 37 | t.deepEqual(log, [0]) 38 | 39 | await frame(4) 40 | 41 | t.deepEqual(log, [0]) 42 | 43 | t.end() 44 | }) 45 | 46 | t('useState: deps reinit', async t => { 47 | let log = [] 48 | 49 | let f = enhook((i) => { 50 | let [v, setV] = useState((prev) => { 51 | log.push(prev) 52 | return 1 53 | }, [i]) 54 | }) 55 | 56 | f(1) 57 | t.deepEqual(log, [undefined]) 58 | 59 | f(2) 60 | t.deepEqual(log, [undefined, 1]) 61 | 62 | t.end() 63 | }) 64 | 65 | t.skip('useState: double set', async t => { 66 | let log = [] 67 | 68 | let fn = enhook((i) => { 69 | let [count, setCount] = useState(i) 70 | 71 | log.push(count) 72 | useEffect(() => { 73 | setCount(i + 1) 74 | setCount(i) 75 | }, []) 76 | }) 77 | fn(1) 78 | await frame(3) 79 | t.deepEqual(log, [1]) 80 | 81 | t.end() 82 | }) 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | dist 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unihooks", 3 | "version": "2.6.0", 4 | "description": "Universal poly-framework react hooks", 5 | "main": "index.js", 6 | "module": "index.js", 7 | "files": [ 8 | "index.js" 9 | ], 10 | "dependencies": { 11 | "any-hooks": "^4.0.1" 12 | }, 13 | "devDependencies": { 14 | "atomico": "^0.16.1", 15 | "augmentor": "^2.1.4", 16 | "dom-augmentor": "^2.0.3", 17 | "enhook": "^3.1.0", 18 | "es-dev-server": "^1.32.0", 19 | "esm": "^3.2.25", 20 | "haunted": "^4.6.3", 21 | "htm": "^3.0.1", 22 | "jsdom": "^16.0.1", 23 | "observable": "^2.1.4", 24 | "parcel": "^1.12.4", 25 | "preact": "^10.1.0", 26 | "tst": "^5.3.1", 27 | "wait-please": "^3.0.0" 28 | }, 29 | "browser": { 30 | "./src/util/timers.js": "./src/util/browser-timers.js" 31 | }, 32 | "browserslist": [ 33 | "last 1 Chrome versions" 34 | ], 35 | "esm": { 36 | "force": true 37 | }, 38 | "scripts": { 39 | "start:es": "es-dev-server --node-resolve --dedupe", 40 | "start": "parcel serve --no-hmr test/index.html", 41 | "test": "node -r esm -r ./test/register.js ./test/index.js" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/unihooks/unihooks.git" 46 | }, 47 | "keywords": [ 48 | "react", 49 | "hooks", 50 | "augmentor", 51 | "preact", 52 | "haunted", 53 | "atomico", 54 | "fuco", 55 | "rax", 56 | "useStore", 57 | "useStorage", 58 | "useQueryString", 59 | "useSearchParam", 60 | "useChannel" 61 | ], 62 | "author": "Dmitry Yv. ", 63 | "license": "MIT", 64 | "bugs": { 65 | "url": "https://github.com/unihooks/unihooks/issues" 66 | }, 67 | "homepage": "https://github.com/unihooks/unihooks#readme" 68 | } 69 | -------------------------------------------------------------------------------- /test/useAction.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import setHooks, { useAction, useEffect, useState, useChannel } from '../' 4 | import { tick, frame, time } from 'wait-please' 5 | 6 | t('useAction: basic', async t => { 7 | let log = [] 8 | 9 | let f = enhook(() => { 10 | let [foo, setFoo] = useChannel('foo', { bar: 'baz' }) 11 | log.push(foo) 12 | let [fooFn] = useAction('foo', () => { 13 | useEffect(() => { 14 | setFoo({ bar: 'qux' }) 15 | }, []) 16 | }) 17 | fooFn() 18 | }) 19 | f() 20 | await tick(2) 21 | t.deepEqual(log, [{ bar: 'baz'}]) 22 | f() 23 | t.deepEqual(log, [{ bar: 'baz'}, { bar: 'qux'}]) 24 | await tick(2) 25 | 26 | f.unhook() 27 | 28 | t.end() 29 | }) 30 | 31 | t('useAction: passes args & returns result', async t => { 32 | let log = [] 33 | let f = enhook(() => { 34 | let action = useAction('args', function (...args) { 35 | log.push(...args) 36 | return 4 37 | }) 38 | log.push(action(1, 2, 3)) 39 | }) 40 | f() 41 | 42 | t.deepEqual(log, [1, 2, 3, 4]) 43 | 44 | f.unhook() 45 | t.end() 46 | }) 47 | 48 | t('useAction: null value', async t => { 49 | let log = [] 50 | let f1 = enhook(() => { 51 | let a = useAction('x') 52 | log.push(a[0]) 53 | }) 54 | let f2 = enhook(() => { 55 | let a = useAction('x', 123) 56 | log.push(a[0]) 57 | }) 58 | f1() 59 | f2() 60 | 61 | t.deepEqual(log,[undefined, 123]) 62 | 63 | f1.unhook() 64 | f2.unhook() 65 | 66 | t.end() 67 | }) 68 | 69 | t('useAction: should support deps', async t => { 70 | let log = [] 71 | let f = enhook((arg) => { 72 | let act = useAction('x', () => log.push(arg), [arg]) 73 | act() 74 | }) 75 | f(1) 76 | f(2) 77 | 78 | t.deepEqual(log, [1, 2]) 79 | f.unhook() 80 | 81 | t.end() 82 | }) 83 | -------------------------------------------------------------------------------- /test/useValidate.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { useValidate, useEffect } from '../' 4 | import { tick, frame, time } from 'wait-please' 5 | 6 | t('useValidate: validates single', async t => { 7 | let log = [] 8 | 9 | let f = enhook((arg) => { 10 | let [err, validate] = useValidate(arg => !!arg) 11 | log.push(arg, validate(arg), err) 12 | }) 13 | 14 | f('') 15 | await time(10) 16 | t.deepEqual(log, ['', false, null, '', false, false]) 17 | 18 | log = [] 19 | f('ok') 20 | await time(10) 21 | t.deepEqual(log, ['ok', true, false, 'ok', true, null]) 22 | 23 | t.end() 24 | }) 25 | 26 | t('useValidate: validates array', async t => { 27 | let log = [] 28 | 29 | let f = enhook((arg) => { 30 | let [err, validate] = useValidate([v => !!v, v => v === 'b']) 31 | log.push(arg, validate(arg), err) 32 | }) 33 | 34 | f('') 35 | await time(10) 36 | t.deepEqual(log, ['', false, null, '', false, false]) 37 | 38 | log = [] 39 | f('a') 40 | await time(10) 41 | t.deepEqual(log, ['a', false, false]) 42 | 43 | log = [] 44 | f('b') 45 | await frame(2) 46 | t.deepEqual(log, ['b', true, false, 'b', true, null]) 47 | 48 | t.end() 49 | }) 50 | 51 | t.skip('useValidate: error in validator', async t => { 52 | let log = [] 53 | 54 | let f = enhook((arg) => { 55 | let [err, validate] = useValidate(arg => xxx) 56 | log.push(arg, validate(arg), err) 57 | }) 58 | 59 | f('') 60 | await time(10) 61 | log[5] = log[5].name 62 | t.deepEqual(log, ['', false, null, '', false, 'ReferenceError']) 63 | 64 | t.end() 65 | }) 66 | 67 | t('useValidate: error in array', async t => { 68 | let log = [] 69 | 70 | let f = enhook((arg) => { 71 | let [err, validate] = useValidate([v => true, v => v === 'b' ? true : 'ugh']) 72 | log.push(arg, validate(arg), err) 73 | }) 74 | 75 | await time(10) 76 | 77 | log = [] 78 | f('a') 79 | await time(10) 80 | t.deepEqual(log, ["a", false, null, 'a', false, 'ugh']) 81 | 82 | t.end() 83 | }) 84 | -------------------------------------------------------------------------------- /test/useAttribute.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import enhook from './enhook.js' 3 | import { tick, frame } from 'wait-please' 4 | import { useAttribute, useEffect, useRef } from '../src/index' 5 | 6 | t('useAttribute: basics', async t => { 7 | let log = [] 8 | let el = document.createElement('div') 9 | el.setAttribute('foo', 'bar') 10 | let f = enhook(() => { 11 | let [foo, setFoo] = useAttribute(el, 'foo') 12 | log.push(foo) 13 | useEffect(() => { 14 | setFoo('baz') 15 | }, []) 16 | }) 17 | 18 | f() 19 | 20 | t.deepEqual(log, ['bar']) 21 | await tick(2) 22 | t.deepEqual(log, ['bar', 'baz']) 23 | t.equal(el.getAttribute('foo'), 'baz') 24 | 25 | el.setAttribute('foo', 'qux') 26 | await tick(2) 27 | t.deepEqual(log, ['bar', 'baz', 'qux']) 28 | 29 | el.setAttribute('bar', 'baz') 30 | await tick(2) 31 | t.deepEqual(log, ['bar', 'baz', 'qux']) 32 | 33 | t.end() 34 | }) 35 | 36 | t('useAttribute: handle ref', async t => { 37 | let log = [] 38 | 39 | let el = document.createElement('div') 40 | el.setAttribute('foo', 'bar') 41 | 42 | let f = enhook(() => { 43 | let ref = useRef() 44 | let [foo, setFoo] = useAttribute(ref, 'foo') 45 | log.push(foo) 46 | ref.current = el 47 | 48 | useEffect(() => () => setFoo()) 49 | }) 50 | f() 51 | 52 | await frame(1) 53 | t.deepEqual(log, [null]) 54 | f() 55 | await frame(2) 56 | t.deepEqual(log, [null, 'bar']) 57 | 58 | f.unhook() 59 | await frame(2) 60 | t.notOk(el.hasAttribute('foo')) 61 | 62 | t.end() 63 | }) 64 | 65 | t('useAttribute: properly read initial values', async t => { 66 | let log = [] 67 | 68 | let el = document.createElement('div') 69 | el.setAttribute('a', '') 70 | el.setAttribute('c', 'xyz') 71 | document.body.appendChild(el) 72 | 73 | enhook(() => { 74 | let [a] = useAttribute(el, 'a') 75 | let [b] = useAttribute(el, 'b') 76 | let [c] = useAttribute(el, 'c') 77 | t.equal(a, true) 78 | t.equal(b, null) 79 | t.equal(c, 'xyz') 80 | })() 81 | 82 | t.end() 83 | }) 84 | -------------------------------------------------------------------------------- /test/useGlobalCache.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useGlobalCache, useEffect } from '../src/index' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame } from 'wait-please' 5 | import globalCache from 'global-cache' 6 | 7 | 8 | t('useGlobalCache: basic', async t => { 9 | globalCache.set('count', 0) 10 | let log = [] 11 | let f = enhook(() => { 12 | let [count, setCount] = useGlobalCache('count') 13 | log.push(count) 14 | useEffect(() => { 15 | setCount(1) 16 | }, []) 17 | }) 18 | f() 19 | t.deepEqual(log, [0]) 20 | await frame(4) 21 | t.deepEqual(log, [0, 1]) 22 | 23 | globalCache.delete('count') 24 | t.end() 25 | }) 26 | 27 | t('useGlobalCache: multiple components use same key', async t => { 28 | let log = [] 29 | 30 | const f = (i, log) => { 31 | let [count, setCount] = useGlobalCache('count', i) 32 | log.push('call', i, count) 33 | useEffect(() => { 34 | log.push('effect', i) 35 | setCount(i) 36 | }, []) 37 | } 38 | let f1 = enhook(f) 39 | let f2 = enhook(f) 40 | 41 | f1(1, log) 42 | t.deepEqual(log, ['call', 1, 1]) 43 | await frame(2) 44 | t.deepEqual(log, ['call', 1, 1, 'effect', 1]) 45 | 46 | f2(2, log) 47 | await frame(4) 48 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2]) 49 | 50 | globalCache.delete('count') 51 | t.end() 52 | }) 53 | 54 | t('useGlobalCache: does not trigger unchanged updates', async t => { 55 | let log = [] 56 | let f = enhook((i) => { 57 | let [count, setCount] = useGlobalCache('count', i) 58 | log.push(count) 59 | useEffect(() => { 60 | setCount(i + 1) 61 | setCount(i) 62 | }, []) 63 | }) 64 | f(1) 65 | t.deepEqual(log, [1]) 66 | await frame(3) 67 | t.deepEqual(log, [1]) 68 | 69 | globalCache.delete('count') 70 | t.end() 71 | }) 72 | 73 | t('useGlobalCache: must be writable', async t => { 74 | globalCache.set('x', 1) 75 | 76 | let log = [] 77 | 78 | enhook(() => { 79 | let [x, setX] = useGlobalCache('x') 80 | log.push(x) 81 | setX(2) 82 | })() 83 | 84 | t.deepEqual(log, [1]) 85 | await tick(3) 86 | t.deepEqual(log, [1, 2]) 87 | 88 | globalCache.delete('x') 89 | t.end() 90 | }) 91 | -------------------------------------------------------------------------------- /test/useProperty.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useProperty, useEffect } from '../src/index' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame } from 'wait-please' 5 | 6 | 7 | t('useProperty: basic', async t => { 8 | let obj = {count: 0} 9 | 10 | let log = [] 11 | let f = enhook(() => { 12 | let [count, setCount] = useProperty(obj, 'count') 13 | log.push(count) 14 | useEffect(() => { 15 | setCount(1) 16 | }, []) 17 | }) 18 | f() 19 | t.deepEqual(log, [0]) 20 | await frame(4) 21 | t.deepEqual(log, [0, 1]) 22 | t.deepEqual(obj, {count: 1}) 23 | 24 | t.end() 25 | }) 26 | 27 | t('useProperty: multiple components use same key', async t => { 28 | let log = [] 29 | 30 | let obj = {count: null} 31 | 32 | const f = (i, log) => { 33 | let [count, setCount] = useProperty(obj, 'count', i) 34 | log.push('call', i, count) 35 | useEffect(() => { 36 | log.push('effect', i) 37 | setCount(i) 38 | }, []) 39 | } 40 | let f1 = enhook(f) 41 | let f2 = enhook(f) 42 | 43 | f1(1, log) 44 | t.deepEqual(log, ['call', 1, 1]) 45 | await frame(2) 46 | t.deepEqual(log, ['call', 1, 1, 'effect', 1]) 47 | 48 | f2(2, log) 49 | await frame(4) 50 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2]) 51 | 52 | t.end() 53 | }) 54 | 55 | t('useProperty: does not trigger unchanged updates', async t => { 56 | let obj = {count: null} 57 | let log = [] 58 | let f = enhook((i) => { 59 | let [count, setCount] = useProperty(obj, 'count', i) 60 | log.push(count) 61 | useEffect(() => { 62 | setCount(i + 1) 63 | setCount(i) 64 | }, []) 65 | }) 66 | f(1) 67 | t.deepEqual(log, [1]) 68 | await frame(2) 69 | t.deepEqual(log, [1]) 70 | 71 | t.end() 72 | }) 73 | 74 | t('useProperty: must be writable', async t => { 75 | let log = [] 76 | let obj = { x : 1 } 77 | 78 | enhook(() => { 79 | let [x, setX] = useProperty(obj, 'x') 80 | log.push(x) 81 | setX(2) 82 | })() 83 | 84 | t.deepEqual(log, [1]) 85 | await tick(3) 86 | t.deepEqual(log, [1, 2]) 87 | 88 | t.end() 89 | }) 90 | 91 | t('useProperty: keeps prev setter/getter', async t => { 92 | let log = [] 93 | let obj = { 94 | _x: 0, 95 | get x() { 96 | log.push('get', this._x); return this._x 97 | }, 98 | set x(x) { 99 | log.push('set', x); 100 | this._x = x 101 | } 102 | } 103 | 104 | let f = enhook(() => { 105 | let [prop, setProp] = useProperty(obj, 'x') 106 | log.push('call', prop) 107 | }) 108 | f() 109 | 110 | t.deepEqual(log, ['get', 0, 'call', 0]) 111 | 112 | obj.x 113 | await frame(2) 114 | t.deepEqual(log, ['get', 0, 'call', 0, 'get', 0]) 115 | 116 | obj.x = 1 117 | await frame(3) 118 | t.deepEqual(log, ['get', 0, 'call', 0, 'get', 0, 'set', 1, 'call', 1]) 119 | 120 | // log = [] 121 | // xs.cancel() 122 | // t.deepEqual(log, []) 123 | 124 | // obj.x 125 | // t.deepEqual(log, ['get', 1]) 126 | 127 | // obj.x = 0 128 | // await frame(2) 129 | // t.deepEqual(log, ['get', 1, 'set', 0]) 130 | 131 | t.end() 132 | }) 133 | 134 | 135 | t.skip('useProperty: observes path', async t => { 136 | t.end() 137 | }) 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /test/useStorage.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useStorage, useEffect } from '../' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame } from 'wait-please' 5 | 6 | t('useStorage: basic', async t => { 7 | localStorage.removeItem('count') 8 | 9 | let log = [] 10 | let f = enhook(() => { 11 | let [count, setCount] = useStorage('count', 0, {prefix: ''}) 12 | log.push(count) 13 | useEffect(() => { 14 | setCount(1) 15 | }, []) 16 | }) 17 | f() 18 | t.deepEqual(log, [0]) 19 | await frame(4) 20 | t.deepEqual(log, [0, 1]) 21 | 22 | await frame(4) 23 | f.unhook() 24 | await frame(4) 25 | localStorage.removeItem('count') 26 | 27 | t.end() 28 | }) 29 | 30 | t('useStorage: should read stored value', async t => { 31 | localStorage.setItem('count', 1) 32 | 33 | let log = [] 34 | let f = enhook(() => { 35 | let [count, setCount] = useStorage('count', null, { prefix: '' }) 36 | log.push(count) 37 | }) 38 | f() 39 | t.deepEqual(log, [1]) 40 | await frame(4) 41 | 42 | f.unhook() 43 | await frame(4) 44 | localStorage.removeItem('count') 45 | 46 | t.end() 47 | }) 48 | 49 | t('useStorage: multiple components use same key', async t => { 50 | let log = [] 51 | 52 | localStorage.removeItem('count') 53 | 54 | const f = (i, log) => { 55 | let [count, setCount] = useStorage('count', i, {prefix: ''}) 56 | log.push('call', i, count) 57 | useEffect(() => { 58 | log.push('effect', i) 59 | setCount(i) 60 | }, []) 61 | } 62 | let f1 = enhook(f) 63 | let f2 = enhook(f) 64 | 65 | f1(1, log) 66 | t.deepEqual(log, ['call', 1, 1]) 67 | await frame(2) 68 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 1, 1]) 69 | 70 | f2(2, log) 71 | await frame(4) 72 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 1, 1, 'call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2]) 73 | 74 | f1.unhook() 75 | f2.unhook() 76 | await frame(4) 77 | localStorage.removeItem('count') 78 | 79 | t.end() 80 | }) 81 | 82 | t('useStorage: does not trigger unchanged updates too many times', async t => { 83 | localStorage.removeItem('count') 84 | 85 | let log = [] 86 | let f = enhook((i) => { 87 | let [count, setCount] = useStorage('count', i, { prefix: ''}) 88 | log.push(count) 89 | useEffect(() => { 90 | setCount(i + 1) 91 | setCount(i + 2) 92 | setCount(i) 93 | }, []) 94 | }) 95 | f(1) 96 | t.deepEqual(log, [1]) 97 | await frame(2) 98 | t.deepEqual(log, [1, 1]) 99 | 100 | f.unhook() 101 | await frame(4) 102 | localStorage.removeItem('count') 103 | 104 | t.end() 105 | }) 106 | 107 | t('useStorage: fn init should be called with initial value', async t => { 108 | localStorage.setItem('count', 0) 109 | 110 | let log = [] 111 | let f = enhook(() => { 112 | let [value] = useStorage('count', (count) => { 113 | log.push(count) 114 | return 1 115 | }, { prefix: '' }) 116 | log.push(value) 117 | }) 118 | f() 119 | t.deepEqual(log, [0, 1]) 120 | 121 | f() 122 | t.deepEqual(log, [0, 1, 1]) 123 | 124 | f.unhook() 125 | await frame(4) 126 | localStorage.removeItem('count') 127 | 128 | t.end() 129 | }) 130 | 131 | t('useStorage: fn sometimes does not trigger', async t => { 132 | let log = [] 133 | let f = enhook(() => { 134 | let [value] = useStorage('x', () => { 135 | log.push(0) 136 | return 1 137 | }) 138 | log.push(value) 139 | }) 140 | f() 141 | t.deepEqual(log, [0, 1]) 142 | 143 | f.unhook() 144 | await frame(4) 145 | 146 | t.end() 147 | }) 148 | 149 | -------------------------------------------------------------------------------- /test/useChannel.js: -------------------------------------------------------------------------------- 1 | import { useChannel, useEffect } from '..' 2 | import t from 'tst' 3 | import enhook from './enhook.js' 4 | import { tick, frame, time } from 'wait-please' 5 | 6 | t('useChannel: does not init twice', async t => { 7 | let log = [] 8 | 9 | let f1 = enhook(() => { 10 | let [value, setValue] = useChannel('a', 1) 11 | log.push(value) 12 | }), f2 = enhook(() => { 13 | let [value, setValue] = useChannel('a', 2) 14 | log.push(value) 15 | }) 16 | 17 | f1() 18 | f2() 19 | await frame(2) 20 | t.deepEqual(log, [1, 1]) 21 | await frame(2) 22 | t.deepEqual(log, [1, 1]) 23 | 24 | f1.unhook() 25 | f2.unhook() 26 | 27 | await frame(2) 28 | 29 | t.end() 30 | }) 31 | 32 | t('useChannel: does not trigger unchanged updates too many times', async t => { 33 | let log = [] 34 | let fn = enhook((i) => { 35 | let [count, setCount] = useChannel('count', i) 36 | 37 | log.push(count) 38 | useEffect(() => { 39 | setCount(i + 1) 40 | setCount(i) 41 | }, []) 42 | }) 43 | fn(1) 44 | t.deepEqual(log, [1]) 45 | await frame(2) 46 | t.deepEqual(log, [1, 1]) 47 | 48 | fn.unhook() 49 | await frame(2) 50 | 51 | t.end() 52 | }) 53 | 54 | t('useChannel: does not call same-value update more than once', async t => { 55 | let log = [] 56 | let f = enhook(() => { 57 | let [a, setA] = useChannel('count') 58 | log.push('a', a) 59 | let [b, setB] = useChannel('count', () => 1) 60 | log.push('b', b) 61 | let [c, setC] = useChannel('count', 2) 62 | log.push('c', c) 63 | useEffect(() => { 64 | setA(2) 65 | setB(2) 66 | setC(2) 67 | }, []) 68 | }) 69 | f() 70 | t.deepEqual(log, ['a', undefined, 'b', 1, 'c', 1]) 71 | 72 | log = [] 73 | await frame(2) 74 | t.deepEqual(log, ['a', 2, 'b', 2, 'c', 2]) 75 | 76 | f.unhook() 77 | 78 | t.end() 79 | }) 80 | 81 | t('useChannel: reinitialize storage is fine', async t => { 82 | let log = [] 83 | 84 | let f = enhook((key, value) => { 85 | let [foo] = useChannel(key, value) 86 | log.push(foo) 87 | }) 88 | f('foo', 'bar') 89 | await frame() 90 | t.deepEqual(log, ['bar']) 91 | f('foo2', 'baz') 92 | await frame() 93 | t.deepEqual(log, ['bar', 'baz']) 94 | 95 | f.unhook() 96 | 97 | t.end() 98 | }) 99 | 100 | t('useChannel: call init if key changes', async t => { 101 | let log = [] 102 | let f = enhook((key) => { 103 | let [value, setValue] = useChannel(key, (oldValue) => { 104 | log.push('init', oldValue) 105 | return key 106 | }) 107 | log.push('render', value) 108 | }) 109 | 110 | f('x') 111 | t.deepEqual(log, ['init', undefined, 'render', 'x']) 112 | await frame(2) 113 | t.deepEqual(log, ['init', undefined, 'render', 'x']) 114 | await frame(2) 115 | 116 | f('x') 117 | t.deepEqual(log, ['init', undefined, 'render', 'x', 'render', 'x']) 118 | await frame(2) 119 | 120 | log = [] 121 | f('y') 122 | t.deepEqual(log, ['init', undefined, 'render', 'y']) 123 | await frame(2) 124 | f('y') 125 | await frame(2) 126 | t.deepEqual(log, ['init', undefined, 'render', 'y', 'render', 'y']) 127 | 128 | f.unhook() 129 | 130 | t.end() 131 | }) 132 | 133 | t('useChannel: async setter is fine', async t => { 134 | let log = [] 135 | 136 | let f = enhook(() => { 137 | let [foo] = useChannel('foo', async () => { 138 | await tick() 139 | return 'foo' 140 | }) 141 | 142 | log.push(foo) 143 | }) 144 | f() 145 | 146 | t.ok(log[0] instanceof Promise) 147 | await time() 148 | t.equal(log[1], 'foo') 149 | 150 | f.unhook() 151 | 152 | t.end() 153 | }) 154 | 155 | t('useStore: multiple components use same key', async t => { 156 | let log = [] 157 | 158 | const f = (i) => { 159 | let [count, setCount] = useChannel('count', i) 160 | log.push('call', i, count) 161 | useEffect(() => { 162 | log.push('effect', i) 163 | setCount(i) 164 | }, []) 165 | } 166 | let f1 = enhook(f) 167 | let f2 = enhook(f) 168 | 169 | f1(1, log) 170 | t.deepEqual(log, ['call', 1, 1]) 171 | await frame(4) 172 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 1, 1]) 173 | 174 | log = [] 175 | f2(2, log) 176 | await frame(4) 177 | t.deepEqualAny(log, [ 178 | ['call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2], 179 | ['call', 2, 1, 'effect', 2, 'call', 1, 2, 'call', 2, 2], 180 | ]) 181 | 182 | f1.unhook() 183 | f2.unhook() 184 | await frame(2) 185 | 186 | t.end() 187 | }) 188 | 189 | t('useChannel: functional setter', async t => { 190 | let log = [] 191 | let f = enhook(() => { 192 | let [count, setCount] = useChannel('count', 0) 193 | log.push(count) 194 | useEffect(() => { 195 | setCount(count => { 196 | return count + 1 197 | }) 198 | }, []) 199 | }) 200 | f() 201 | t.deepEqual(log, [0]) 202 | await frame(2) 203 | t.deepEqual(log, [0, 1]) 204 | f.unhook() 205 | 206 | t.end() 207 | }) 208 | 209 | t('useChannel: init must be called once per key', async t => { 210 | let log = [] 211 | let f = enhook(() => { 212 | let [a, setA] = useChannel('a', () => { 213 | log.push(1) 214 | }) 215 | let [a1, setA1] = useChannel('a', () => { 216 | log.push(2) 217 | }) 218 | }) 219 | let f2 = enhook(() => { 220 | let [a, setA] = useChannel('a', () => { 221 | log.push(3) 222 | }) 223 | }) 224 | f() 225 | f2() 226 | t.deepEqual(log, [1]) 227 | f.unhook() 228 | t.end() 229 | }) 230 | 231 | t('useChannel: useQueryParam atomico case (must not fail)', async t => { 232 | let f1 = enhook(() => { 233 | let [a, set] = useChannel('a') 234 | }) 235 | f1() 236 | f1.unhook() 237 | 238 | let f2 = enhook((params) => { 239 | let [a, set] = useChannel('a') 240 | set(1) 241 | }) 242 | f2() 243 | f2.unhook() 244 | 245 | await frame(2) 246 | 247 | t.end() 248 | }) 249 | -------------------------------------------------------------------------------- /test/useStore.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useStore, useEffect, useState } from '../src/index' 3 | import { INTERVAL, storage, PREFIX, channels, createStore } from '../src/useStore' 4 | import enhook from './enhook.js' 5 | import { tick, idle, frame, time } from 'wait-please' 6 | import { clearNodeFolder } from 'broadcast-channel' 7 | 8 | t('useStore: debounce is 300ms', async t => { 9 | await clearNodeFolder() 10 | storage.set(PREFIX + 'count', undefined) 11 | 12 | let log = [] 13 | let f = enhook(() => { 14 | let [count, setCount] = useStore('count', 0) 15 | log.push(count) 16 | useEffect(() => { 17 | setCount(1) 18 | }, []) 19 | }) 20 | f() 21 | t.deepEqual(log, [0]) 22 | await tick(2) 23 | // await time(INTERVAL) 24 | t.deepEqual(log, [0, 1]) 25 | 26 | await teardown() 27 | t.end() 28 | }) 29 | 30 | t('useStore: multiple components use same key', async t => { 31 | await clearNodeFolder() 32 | storage.set(PREFIX + 'count', null) 33 | 34 | let log = [] 35 | 36 | 37 | const f = (i, log) => { 38 | let [count, setCount] = useStore('count', i) 39 | log.push('call', i, count) 40 | useEffect(() => { 41 | log.push('effect', i) 42 | setCount(i) 43 | }, []) 44 | } 45 | let f1 = enhook(f) 46 | let f2 = enhook(f) 47 | 48 | f1(1, log) 49 | t.deepEqual(log, ['call', 1, 1]) 50 | await time(INTERVAL) 51 | t.deepEqual(log, ['call', 1, 1, 'effect', 1]) 52 | 53 | f2(2, log) 54 | await time(INTERVAL * 2) 55 | t.deepEqual(log, ['call', 1, 1, 'effect', 1, 'call', 2, 1, 'effect', 2, 'call', 2, 2, 'call', 1, 2]) 56 | 57 | await time(INTERVAL) 58 | await teardown() 59 | t.end() 60 | }) 61 | 62 | t('useStore: does not trigger unchanged updates', async t => { 63 | await clearNodeFolder() 64 | storage.set(PREFIX + 'count', null) 65 | 66 | let log = [] 67 | let fn = enhook((i) => { 68 | let [count, setCount] = useStore('count', i) 69 | 70 | log.push(count) 71 | useEffect(() => { 72 | setCount(i + 1) 73 | setCount(i) 74 | }, []) 75 | }) 76 | fn(1) 77 | t.deepEqual(log, [1]) 78 | await time(INTERVAL) 79 | t.deepEqual(log, [1]) 80 | 81 | await time(INTERVAL * 2) 82 | await teardown() 83 | t.end() 84 | }) 85 | 86 | t('useStore: fn init should be called per hook', async t => { 87 | await clearNodeFolder() 88 | storage.set(PREFIX + 'count', null) 89 | 90 | createStore('count', 0) 91 | 92 | let log = [] 93 | let f = enhook(() => { 94 | useStore('count', -1) 95 | useStore('count', (count) => { 96 | log.push('init', count) 97 | return 1 98 | }) 99 | let [value, setValue] = useStore('count', (count) => { 100 | log.push('reinit', count) 101 | return 2 102 | }) 103 | log.push('call', value) 104 | }) 105 | f() 106 | t.deepEqual(log, ['init', 0, 'reinit', 1, 'call', 2]) 107 | 108 | log = [] 109 | f() 110 | t.deepEqual(log, ['call', 2]) 111 | 112 | await time(INTERVAL) 113 | await teardown() 114 | 115 | t.end() 116 | }) 117 | 118 | t.skip('useStore: broadcast', async t => { 119 | // let React = await import('react') 120 | // let ReactDOM = await import('react-dom') 121 | 122 | // let el = document.createElement('div') 123 | // document.body.appendChild(el) 124 | 125 | // ReactDOM.render(, el) 126 | 127 | // function App() { 128 | // let [value, setValue] = useStore('foo', 0, { persist: true }) 129 | 130 | // return 133 | // } 134 | 135 | t.end() 136 | }) 137 | 138 | 139 | 140 | t('useStore: functional setter', async t => { 141 | await clearNodeFolder() 142 | storage.set(PREFIX + 'count', undefined) 143 | 144 | let log = [] 145 | let f = enhook(() => { 146 | let [count, setCount] = useStore('count', 0) 147 | log.push(count) 148 | useEffect(() => { 149 | setCount(count => { 150 | return count + 1 151 | }) 152 | }, []) 153 | }) 154 | f() 155 | t.deepEqual(log, [0]) 156 | await tick(2) 157 | // await time(INTERVAL) 158 | t.deepEqual(log, [0, 1]) 159 | 160 | await teardown() 161 | t.end() 162 | }) 163 | 164 | t('useStore: createStore must not rewrite existing data', async t => { 165 | await clearNodeFolder() 166 | storage.set(PREFIX + 'count', undefined) 167 | 168 | storage.set(PREFIX + 'xxx', {x: 1}) 169 | 170 | let s = createStore('xxx', {y: 2}) 171 | t.deepEqual(s, {x: 1}) 172 | 173 | storage.set(PREFIX + 'xxx', null) 174 | await teardown() 175 | t.end() 176 | }) 177 | 178 | t('useStore: persistence serializes store', async t => { 179 | await clearNodeFolder() 180 | 181 | let log = [] 182 | 183 | let s = createStore('x', 1) 184 | let f1 = enhook(() => { 185 | let [x, setX] = useStore('x') 186 | setX(2) 187 | }, { passive: true }) 188 | f1() 189 | 190 | await time(INTERVAL * 2) 191 | t.deepEqual(storage.get(PREFIX + 'x'), 2) 192 | 193 | storage.set(PREFIX + 'x', null) 194 | await teardown() 195 | t.end() 196 | }) 197 | 198 | t('useStore: adjacent tabs do not cause recursion', async t => { 199 | // FIXME: spawn a copy in a separate tab 200 | await clearNodeFolder() 201 | 202 | let log = [] 203 | 204 | let f1 = enhook(() => { 205 | let [x, setX] = useStore('x') 206 | useEffect(() => { 207 | setX({x: [1]}) 208 | }, []) 209 | log.push(x) 210 | }) 211 | f1() 212 | 213 | await time(INTERVAL * 6) 214 | t.deepEqual(log.filter(Boolean), [{x: [1]}]) 215 | 216 | storage.set(PREFIX + 'x', null) 217 | await teardown() 218 | t.end() 219 | }) 220 | 221 | t('useStore: removing components must not close channel', async t => { 222 | await clearNodeFolder() 223 | 224 | let log = [] 225 | 226 | let f1 = enhook(() => { 227 | let [x, setX] = useStore('x') 228 | useEffect(() => setX(1), []) 229 | }) 230 | f1() 231 | let f2 = enhook((v) => { 232 | let [x, setX] = useStore('x') 233 | useEffect(() => setX(v), [v]) 234 | }) 235 | 236 | await frame() 237 | f1.unhook() 238 | await frame() 239 | 240 | f2(2) 241 | await frame() 242 | f2.unhook() 243 | await frame() 244 | 245 | await teardown() 246 | t.end() 247 | }) 248 | 249 | 250 | export async function teardown() { 251 | storage.set(PREFIX + 'count', null) 252 | for (let channel in channels) { (channels[channel].close(), delete channels[channel]) } 253 | } 254 | -------------------------------------------------------------------------------- /test/useSearchParam.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useSearchParam } from '../' 3 | import enhook from './enhook.js' 4 | import { tick, idle, frame, time } from 'wait-please' 5 | 6 | t.browser('useSearchParam: read values properly', async t => { 7 | clearSearch() 8 | let log = [] 9 | 10 | let str = new URLSearchParams({ 11 | str: 'foo', 12 | num: -123, 13 | bool: false, 14 | nul: null, 15 | undef: undefined, 16 | arr1: [1, 2, 3], 17 | arr2: ['a', 'b', 'c'], 18 | arr3: [['a', 1], ['b', 2], ['c', 3]], 19 | arr4: [ 20 | { a: 1, toString() {return 'a:' + this.a} }, 21 | { b: 2, toString() { return 'b:' + this.b } }, 22 | { c: 3, toString() { return 'c:' + this.c } } 23 | ], 24 | obj: { a: 1, b: 2, c: 3, foo: 'bar', toString(){return JSON.stringify(this)} }, 25 | date: +new Date('2019-11-17') 26 | }).toString() 27 | window.history.pushState(null, '', '?' + str) 28 | 29 | let f = enhook(() => { 30 | let [str, setStr] = useSearchParam('str') 31 | let [num, setNum] = useSearchParam('num') 32 | let [bool, setBool] = useSearchParam('bool') 33 | let [nul, setNul] = useSearchParam('nul') 34 | let [undef, setUndef] = useSearchParam('undefined') 35 | let [arr1, setArr1] = useSearchParam('arr1') 36 | let [arr2, setArr2] = useSearchParam('arr2') 37 | let [arr3, setArr3] = useSearchParam('arr3') 38 | let [arr4, setArr4] = useSearchParam('arr4') 39 | let [obj, setObj] = useSearchParam('obj') 40 | let [date, setDate] = useSearchParam('date') 41 | 42 | log.push(str) 43 | log.push(num) 44 | log.push(bool) 45 | log.push(nul) 46 | log.push(undef) 47 | log.push(arr1) 48 | log.push(arr2) 49 | log.push(arr3) 50 | log.push(arr4) 51 | log.push(obj) 52 | log.push(date) 53 | }) 54 | 55 | f() 56 | t.deepEqual(log, [ 57 | 'foo', 58 | '-123', 59 | 'false', 60 | 'null', 61 | undefined, 62 | '1,2,3', 63 | 'a,b,c', 64 | 'a,1,b,2,c,3', 65 | 'a:1,b:2,c:3', 66 | '{"a":1,"b":2,"c":3,"foo":"bar"}', 67 | +new Date('2019-11-17T00:00:00.000Z') + '' 68 | ]) 69 | 70 | f.unhook() 71 | clearSearch() 72 | await frame(5) 73 | t.end() 74 | }) 75 | 76 | t.browser('useSearchParam: write values', async t => { 77 | clearSearch() 78 | 79 | let f = enhook((params) => { 80 | let [str, setStr] = useSearchParam('str') 81 | let [num, setNum] = useSearchParam('num') 82 | let [bool, setBool] = useSearchParam('bool') 83 | let [arr1, setArr1] = useSearchParam('arr1') 84 | let [arr2, setArr2] = useSearchParam('arr2') 85 | let [arr3, setArr3] = useSearchParam('arr3') 86 | let [arr4, setArr4] = useSearchParam('arr4') 87 | let [obj, setObj] = useSearchParam('obj') 88 | let [date, setDate] = useSearchParam('date') 89 | 90 | setStr(params.str) 91 | setNum(params.num) 92 | setBool(params.bool) 93 | setArr1(params.arr1) 94 | setArr2(params.arr2) 95 | setArr3(params.arr3) 96 | setArr4(params.arr4) 97 | setObj(params.obj) 98 | setDate(params.date) 99 | }) 100 | 101 | f({ 102 | str: 'foo', 103 | num: -124, 104 | bool: false, 105 | arr1: [1, 2, 3], 106 | arr2: ['a', 'b', 'c'], 107 | arr3: [['a', 1], ['b', 2], ['c', 3]], 108 | arr4: [ 109 | { a: 1, toString() { return 'a:' + this.a } }, 110 | { b: 2, toString() { return 'b:' + this.b } }, 111 | { c: 3, toString() { return 'c:' + this.c } } 112 | ], 113 | obj: { a: 1, b: 2, c: 3, foo: 'bar', toString(){return JSON.stringify(this)} }, 114 | date: new Date('2019-11-17').toISOString() 115 | }) 116 | await frame(2) 117 | let params = new URLSearchParams(window.location.search.slice(1)) 118 | t.is(params.get("arr1"), "1,2,3") 119 | t.is(params.get("arr2"), "a,b,c") 120 | t.is(params.get("arr3"), "a,1,b,2,c,3") 121 | t.is(params.get("arr4"), "a:1,b:2,c:3") 122 | t.is(params.get("bool"), "false") 123 | t.is(params.get("date"), "2019-11-17T00:00:00.000Z") 124 | t.is(params.get("num"), "-124") 125 | t.is(params.get("obj"), JSON.stringify({ "a": 1, "b": 2, "c": 3, "foo": "bar" })) 126 | t.is(params.get("str"), "foo") 127 | 128 | await frame(3) 129 | f.unhook() 130 | // clearSearch() 131 | await frame(3) 132 | t.end() 133 | }) 134 | 135 | t.browser('useSearchParam: defaults', async t => { 136 | let log = [] 137 | 138 | clearSearch() 139 | await time(100) 140 | 141 | let f = enhook(() => { 142 | let [str, setStr] = useSearchParam('str', 'foo') 143 | let [num, setNum] = useSearchParam('num', '-123') 144 | let [bool, setBool] = useSearchParam('bool', 'false') 145 | 146 | log.push(str) 147 | log.push(num) 148 | log.push(bool) 149 | }) 150 | 151 | f() 152 | t.deepEqual(log, [ 153 | 'foo', 154 | '-123', 155 | 'false' 156 | ]) 157 | await tick() 158 | 159 | let params = new URLSearchParams(window.location.search.slice(1)) 160 | 161 | t.equal(params.get("bool"), "false") 162 | t.equal(params.get("num"), "-123") 163 | t.equal(params.get("str"), "foo") 164 | 165 | 166 | f.unhook() 167 | await time(50) 168 | // clearSearch() 169 | t.end() 170 | }) 171 | 172 | t.browser('useSearchParam: observe updates', async t => { 173 | clearSearch() 174 | let log = [] 175 | let f = enhook(() => { 176 | let [v, setV] = useSearchParam('x', 1) 177 | log.push(v) 178 | }) 179 | f() 180 | await frame(2) 181 | t.deepEqual(log, [1]) 182 | 183 | window.history.pushState(null, "useSearchParam", "?x=2"); 184 | await frame() 185 | t.deepEqual(log, [1, '2']) 186 | 187 | window.history.pushState(null, "useSearchParam", "?x=3"); 188 | await frame(3) 189 | t.deepEqual(log, [1, '2', '3']) 190 | 191 | f.unhook() 192 | clearSearch() 193 | t.end() 194 | }) 195 | 196 | t.browser('useSearchParam: default array', async t => { 197 | let f = enhook(() => { 198 | let [arr, setArr] = useSearchParam('arr', []) 199 | t.deepEqual(arr, []) 200 | }) 201 | f() 202 | 203 | await frame(2) 204 | f.unhook() 205 | clearSearch() 206 | t.end() 207 | }) 208 | 209 | t.browser('useSearchParam: init from empty string', async t => { 210 | let log = [] 211 | 212 | window.history.pushState(null, '', '?x=1') 213 | let f = enhook(() => { 214 | let [x] = useSearchParam('x', '') 215 | log.push(x) 216 | }) 217 | f() 218 | t.deepEqual(log, ['1']) 219 | 220 | f.unhook() 221 | 222 | clearSearch() 223 | t.end() 224 | }) 225 | 226 | function clearSearch() { 227 | window.history.pushState(null, '', window.location.href.split('?')[0]) 228 | } 229 | -------------------------------------------------------------------------------- /test/useFormField.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { useFormField, useEffect, useState } from '../' 3 | import { render } from 'preact' 4 | import { html } from 'htm/preact' 5 | import { tick, time, frame } from 'wait-please' 6 | 7 | t('useFormField: should control existing input via actions', async t => { 8 | let el = document.createElement('div') 9 | // document.body.appendChild(el) 10 | 11 | let log = [] 12 | 13 | let Comp = () => { 14 | let field = useFormField({name: 'x', value: 1}) 15 | useEffect(() => { 16 | log.push(field.value, field.error, field.touched) 17 | field.set(2) 18 | setTimeout(() => { 19 | log.push(field.value, field.error, field.touched) 20 | field.set(null) 21 | }, 10) 22 | setTimeout(() => { 23 | log.push(field.value, field.error, field.touched) 24 | field.reset() 25 | }, 20) 26 | setTimeout(() => { 27 | log.push(field.value, field.error, field.touched) 28 | }, 30); 29 | }, []) 30 | return html`` 31 | } 32 | 33 | render(html`<${Comp}/>`, el) 34 | 35 | await time(50) 36 | t.deepEqual(log, [ 37 | 1, null, false, 38 | 2, null, false, 39 | null, null, false, 40 | 1, null, false 41 | ]) 42 | 43 | // document.body.removeChild(el) 44 | render(null, el) 45 | 46 | t.end() 47 | }) 48 | 49 | t('useFormField: state should reflect interactions', async t => { 50 | let el = document.createElement('div') 51 | // document.body.appendChild(el) 52 | 53 | let log = [] 54 | let Comp = () => { 55 | let [props,field] = useFormField() 56 | log.push(field.value, field.touched) 57 | return html`` 58 | } 59 | render(html`<${Comp}/>`, el) 60 | 61 | await frame() 62 | 63 | let input = el.querySelector('input') 64 | input.dispatchEvent(new Event('focus')) 65 | input.value = 'a' 66 | input.dispatchEvent(new InputEvent('input', { data: 'a'})) 67 | 68 | await frame(2) 69 | t.deepEqual(log, ['', false, 'a', true]) 70 | 71 | // document.body.removeChild(el) 72 | render(null, el) 73 | 74 | t.end() 75 | }) 76 | 77 | t('useFormField: should be able to set value', async t => { 78 | let el = document.createElement('div') 79 | // document.body.appendChild(el) 80 | 81 | let log = [] 82 | let Comp = () => { 83 | let field = useFormField({value: 'foo'}) 84 | useEffect(() => { 85 | field.set('bar') 86 | }, []) 87 | return html`` 88 | } 89 | render(html`<${Comp}/>`, el) 90 | 91 | let input = el.querySelector('input') 92 | t.deepEqual(input.value, 'foo') 93 | await frame(2) 94 | t.deepEqual(input.value, 'bar') 95 | 96 | // document.body.removeChild(el) 97 | render(null, el) 98 | 99 | t.end() 100 | }) 101 | 102 | t('useFormField: should be able to validate value', async t => { 103 | let el = document.createElement('div') 104 | // document.body.appendChild(el) 105 | 106 | let log = [] 107 | let Comp = () => { 108 | let field = useFormField({ validate: value => !!value }) 109 | log.push(field.error, field.valid) 110 | return html`` 111 | } 112 | render(html`<${Comp}/>`, el) 113 | 114 | let input = el.querySelector('input') 115 | input.dispatchEvent(new Event('focus')) 116 | input.value = '' 117 | input.dispatchEvent(new Event('input', { data: '' })) 118 | input.dispatchEvent(new Event('blur')) 119 | 120 | t.deepEqual(log, [null, true]) 121 | await frame(2) 122 | t.deepEqual(log, [null, true, false, false]) 123 | 124 | input.dispatchEvent(new Event('focus')) 125 | input.value = 'foo' 126 | input.dispatchEvent(new Event('input', { data: 'foo' })) 127 | input.dispatchEvent(new Event('blur')) 128 | 129 | await frame(2) 130 | t.deepEqual(log, [null, true, false, false, null, true]) 131 | 132 | // document.body.removeChild(el) 133 | render(null, el) 134 | 135 | t.end() 136 | }) 137 | 138 | t('useFormField: does not crash on null-validation', async t => { 139 | let el = document.createElement('div') 140 | // document.body.appendChild(el) 141 | 142 | let log = [] 143 | let Comp = () => { 144 | let field = useFormField() 145 | log.push(field.error) 146 | return html`` 147 | } 148 | render(html`<${Comp}/>`, el) 149 | 150 | let input = el.querySelector('input') 151 | input.dispatchEvent(new Event('focus')) 152 | input.value = '' 153 | input.dispatchEvent(new Event('input', { data: '' })) 154 | input.dispatchEvent(new Event('blur')) 155 | 156 | t.deepEqual(log, [null]) 157 | await frame(2) 158 | t.deepEqual(log, [null, null]) 159 | 160 | input.dispatchEvent(new Event('focus')) 161 | input.value = 'foo' 162 | input.dispatchEvent(new Event('input', { data: 'foo' })) 163 | input.dispatchEvent(new Event('blur')) 164 | 165 | await frame(2) 166 | t.deepEqual(log, [null, null, null]) 167 | 168 | // document.body.removeChild(el) 169 | render(null, el) 170 | 171 | t.end() 172 | }) 173 | 174 | t.skip('useFormField: persist test', async t => { 175 | let el = document.createElement('div') 176 | document.body.appendChild(el) 177 | 178 | let Comp = () => { 179 | let field = useFormField({ persist: true }) 180 | return html`` 181 | } 182 | render(html`<${Comp}/>`, el) 183 | 184 | await frame(2) 185 | 186 | // document.body.removeChild(el) 187 | // render(null, el) 188 | 189 | t.end() 190 | }) 191 | 192 | t('useFormField: focus must reflect focused state', async t => { 193 | let el = document.createElement('div') 194 | 195 | let log = [] 196 | render(html`<${function () { 197 | let field = useFormField('') 198 | log.push(field.focus) 199 | return html`` 200 | }}/>`, el) 201 | let input = el.querySelector('input') 202 | await frame(1) 203 | input.dispatchEvent(new Event('focus')) 204 | await frame(1) 205 | input.dispatchEvent(new Event('blur')) 206 | await frame(1) 207 | 208 | t.deepEqual(log, [false, true, false]) 209 | 210 | render(null, el) 211 | t.end() 212 | }) 213 | 214 | t('useFormField: should not be validated if focused', async t => { 215 | let el = document.createElement('div') 216 | // document.body.appendChild(el) 217 | 218 | let log = [] 219 | render(html` 220 | <${function () { 221 | let field = useFormField({validate(value) { return value ? 'Valid' : 'Invalid' }}) 222 | log.push(field.error) 223 | return html` ${ field.error + '' }` 224 | }}/> 225 | `, el) 226 | 227 | let input = el.querySelector('input') 228 | input.dispatchEvent(new Event('focus')) 229 | input.value = 'a' 230 | input.dispatchEvent(new InputEvent('input')) 231 | 232 | await frame(2) 233 | // t.deepEqual(log, [null, 'Valid']) 234 | t.deepEqual(log, [null, null]) 235 | 236 | input.value = '' 237 | input.dispatchEvent(new InputEvent('input')) 238 | 239 | await frame(2) 240 | t.deepEqual(log, [null, null, null]) 241 | 242 | input.dispatchEvent(new Event('blur')) 243 | await frame(2) 244 | t.deepEqual(log, [null, null, null, 'Invalid']) 245 | 246 | render(null, el) 247 | 248 | t.end() 249 | }) 250 | 251 | t('useFormField: `required` should turn initial valid state into false', async t => { 252 | let el = document.createElement('div') 253 | // document.body.appendChild(el) 254 | 255 | let log = [] 256 | render(html` 257 | <${function () { 258 | let field = useFormField({ required: true }) 259 | log.push(field.valid) 260 | return html` ${field.error + ''}` 261 | }}/> 262 | `, el) 263 | await frame(2) 264 | 265 | let input = el.querySelector('input') 266 | t.deepEqual(log, [true]) 267 | 268 | input.dispatchEvent(new Event('focus')) 269 | input.value = 'a' 270 | input.dispatchEvent(new InputEvent('input')) 271 | await frame(2) 272 | t.deepEqual(log, [true, true]) 273 | 274 | input.value = '' 275 | input.dispatchEvent(new InputEvent('input')) 276 | input.dispatchEvent(new Event('blur')) 277 | 278 | await frame(2) 279 | t.deepEqual(log, [true, true, false]) 280 | render(null, el) 281 | 282 | t.end() 283 | }) 284 | 285 | t('useFormField: changed input props must be updated', async t => { 286 | let el = document.createElement('div') 287 | let log = [] 288 | 289 | render(html`<${function () { 290 | let [x, setX] = useState([1]) 291 | let field = useFormField({ x }) 292 | log.push(field.x) 293 | useEffect(() => setX([2]), []) 294 | 295 | return null 296 | }}/>`, el) 297 | 298 | await frame(3) 299 | t.deepEqual(log, [[1], [2]]) 300 | 301 | render(null, el) 302 | 303 | t.end() 304 | }) 305 | 306 | t.todo('useFormField: initial value should not be null') 307 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import setHooks, * as hooks from "any-hooks" 2 | 3 | export * from 'any-hooks' 4 | export default setHooks 5 | 6 | const listeners = globalThis.__uhxListeners || (globalThis.__uhxListeners = {}), 7 | values = globalThis.__uhxValues || (globalThis.__uhxValues = {}) 8 | 9 | export function useChannel(key, init, deps=[]) { 10 | let [, setState] = hooks.useState() 11 | let initKey = hooks.useRef() 12 | 13 | hooks.useMemo(() => { 14 | (listeners[key] || (listeners[key] = [])).push(setState) 15 | 16 | if (init === undefined) return 17 | // if (key in values) return console.warn(`Channel ${key} is already initialized.`) 18 | if ( 19 | !(key in values) || // init run 20 | initKey.current === key // or deps changed 21 | ) { 22 | initKey.current = key 23 | if (typeof init === 'function') init = init() 24 | values[key] = init 25 | if (init && init.then) init.then(init => setState(values[key] = init)) 26 | } 27 | }, [key, ...deps]) 28 | 29 | hooks.useLayoutEffect(() => () => { 30 | listeners[key].splice(listeners[key].indexOf(setState) >>> 0, 1) 31 | if (!listeners[key].length) { 32 | delete values[key] 33 | } 34 | }, [key, ...deps]) 35 | 36 | return [values[key], (value) => { 37 | values[key] = typeof value === 'function' ? value(values[key]) : value 38 | listeners[key].map((setState) => setState(value)) 39 | }] 40 | } 41 | 42 | export function useAction(key, init, deps = []) { 43 | let [action, setAction] = useChannel('__uhx:action-' + key, init !== undefined ? () => init : undefined, deps) 44 | return hooks.useMemo(() => { 45 | if (typeof action !== 'function') return [action] 46 | action[Symbol.iterator] = function*() { yield action; yield setAction; } 47 | return action 48 | }, [action, ...deps]) 49 | } 50 | 51 | export function useStorage(key, init, o) { 52 | o = { storage: window.localStorage, prefix: '__uhx:storage-', ...(o || {}) } 53 | let storeKey = o.prefix + key 54 | let [value, setValue] = useChannel(key, () => { 55 | // init from stored value, if any 56 | let stored = o.storage.getItem(storeKey) 57 | if (stored != null) stored = JSON.parse(stored) 58 | if (typeof init === 'function') return init(stored) 59 | return stored == null ? init : stored 60 | }) 61 | 62 | hooks.useMemo(() => { 63 | // persist initial value, if store is rewired 64 | if (init === undefined) return 65 | if (o.storage.getItem(storeKey) == null) o.storage.setItem(storeKey, JSON.stringify(value)) 66 | }, [key]) 67 | 68 | hooks.useEffect(() => { 69 | // notify listeners, subscribe to storage changes 70 | let update = e => { 71 | if (e.key !== storeKey) return 72 | setValue(e.newValue) 73 | } 74 | window.addEventListener('storage', update) 75 | return () => window.removeEventListener('storage', update) 76 | }, [key]) 77 | 78 | return [value, (value) => { 79 | setValue(value) 80 | // side-effect write 81 | o.storage.setItem(storeKey, JSON.stringify(value)) 82 | }] 83 | } 84 | 85 | wrapHistory('push') 86 | wrapHistory('replace') 87 | enableNavigateEvent() 88 | export function useSearchParam(key, init) { 89 | let [value, setValue] = useChannel('__uhx:search-param-' + key, () => { 90 | let params = new URLSearchParams(window.location.search) 91 | let param 92 | if (params.has(key)) param = params.get(key) 93 | if (typeof init === 'function') return init(param) 94 | return param == null ? init : param 95 | }) 96 | 97 | hooks.useMemo(() => { 98 | if (init === undefined) return 99 | let params = new URLSearchParams(window.location.search) 100 | if (!params.has(key)) { 101 | params.set(key, value) 102 | setURLSearchParams(params) 103 | } 104 | }, [key]) 105 | 106 | hooks.useEffect(() => { 107 | const update = (e) => { 108 | let params = new URLSearchParams(window.location.search) 109 | let newValue = params.get(key) 110 | // FIXME: there's a test case failing here from wishbox - lang didn't switch more than once. 111 | // if (newValue !== value) setValue(newValue) 112 | setValue(newValue) 113 | } 114 | 115 | window.addEventListener('popstate', update) 116 | window.addEventListener('pushstate', update) 117 | window.addEventListener('replacestate', update) 118 | window.addEventListener('navigate', update) 119 | 120 | return () => { 121 | window.removeEventListener('popstate', update) 122 | window.removeEventListener('pushstate', update) 123 | window.removeEventListener('replacestate', update) 124 | window.removeEventListener('navigate', update) 125 | } 126 | }, [key]) 127 | 128 | return [value, value => { 129 | let params = new URLSearchParams(window.location.search) 130 | params.set(key, value) 131 | setURLSearchParams(params) 132 | }] 133 | } 134 | function setURLSearchParams(params) { 135 | let str = params.toString() 136 | window.history.replaceState(null, '', str ? '?' + str : window.location.href.split('?')[0]) 137 | } 138 | // https://stackoverflow.com/a/25673946/1052640 139 | // https://github.com/lukeed/navaid/blob/master/src/index.js#L80-L90 140 | function wrapHistory(type) { 141 | type += 'State' 142 | const fn = history[type] 143 | history[type] = function (uri) { 144 | let result = fn.apply(this, arguments) 145 | let ev = new Event(type.toLowerCase()) 146 | ev.uri = uri 147 | ev.arguments = arguments 148 | window.dispatchEvent(ev) 149 | return result 150 | } 151 | return () => { 152 | history[type] = fn 153 | } 154 | } 155 | // https://github.com/WebReflection/onpushstate 156 | // https://github.com/lukeed/navaid/blob/master/src/index.js#L52-L60 157 | function enableNavigateEvent() { 158 | const handleNavigate = (e) => { 159 | // find the link node (even if inside an opened Shadow DOM) 160 | var target = e.target.shadowRoot ? e.path[0] : e.target; 161 | // find the anchor 162 | var anchor = target.closest('A') 163 | 164 | // if found 165 | if (!anchor) return 166 | 167 | // it's not a click with ctrl/shift/alt keys pressed 168 | // => (let the browser do it's job instead) 169 | if (!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && !e.button && !e.defaultPrevented) return 170 | 171 | // it's for the current page 172 | if (!/^(?:_self)?$/i.test(anchor.target)) return 173 | 174 | // same origin 175 | if (anchor.host !== location.host) return 176 | 177 | // it's not a download 178 | if (anchor.hasAttribute('download')) return 179 | 180 | // it's not a resource handled externally 181 | if (anchor.getAttribute('rel') === 'external') return 182 | 183 | // let empty links be (see issue #5) 184 | if (!anchor.href) return 185 | 186 | var e = new Event('navigate'); 187 | window.dispatchEvent(e); 188 | } 189 | document.addEventListener('click', handleNavigate, true) 190 | return () => document.removeEventListener('click', handleNavigate) 191 | } 192 | 193 | export function useCountdown(n, interval = 1000) { 194 | const [count, set] = hooks.useState(n) 195 | 196 | const reset = hooks.useCallback(() => set(n), [n]) 197 | 198 | const schedule = hooks.useMemo(() => { 199 | return typeof interval === 'function' ? interval : fn => { 200 | let id = setInterval(fn, interval) 201 | return () => clearInterval(id) 202 | } 203 | }, [interval]) 204 | 205 | hooks.useEffect(() => { 206 | const unschedule = schedule(() => { 207 | set(count => { 208 | if (count <= 0) return (unschedule(), 0) 209 | else return count - 1 210 | }) 211 | }) 212 | 213 | return unschedule 214 | }, [n, schedule]) 215 | 216 | return [count, reset] 217 | } 218 | 219 | export function usePrevious(value) { 220 | let ref = hooks.useRef() 221 | 222 | hooks.useEffect(() => { 223 | ref.current = value 224 | }, [value]) 225 | 226 | return ref.current 227 | } 228 | 229 | export function useUpdate() { 230 | let [, set] = hooks.useState(0) 231 | let update = hooks.useCallback(() => set(s => ~s), []) 232 | return update 233 | } 234 | 235 | export function useValidate(validator, init) { 236 | let check = hooks.useCallback((value) => { 237 | if (!validator) return null 238 | 239 | let check = (value, validator) => { 240 | try { 241 | var valid = validator(value) 242 | if (valid === true || valid == null) { 243 | return null 244 | } 245 | throw valid 246 | } catch (e) { 247 | return e 248 | } 249 | } 250 | 251 | if (Array.isArray(validator)) { 252 | for (let i = 0, error; i < validator.length; i++) { 253 | error = check(value, validator[i]) 254 | if (error !== null) return error 255 | } 256 | return null 257 | } 258 | 259 | return check(value, validator) 260 | }, []) 261 | 262 | 263 | let [error, setError] = hooks.useState(init != null ? () => check(init) : null) 264 | 265 | let validate = hooks.useCallback((value) => { 266 | let error = check(value, validator) 267 | setError(error) 268 | return error == null 269 | }, []) 270 | 271 | return [error, validate] 272 | } 273 | 274 | export function useFormField(props = {}) { 275 | const prefix = '__uhx:form-field-' 276 | 277 | let { value='', validate: rules, required, persist, ...inputProps } = props 278 | let key = inputProps.name || inputProps.id || inputProps.key 279 | 280 | let inputRef = hooks.useRef() 281 | let [init, setValue] = persist ? useStorage(key, value, { storage: window.sessionStorage, prefix }) : useChannel(key, value) 282 | let [focus, setFocus] = hooks.useState(false) 283 | let [error, validate] = useValidate(rules || (required ? v => !!v : null)) 284 | 285 | let field = hooks.useMemo(() => { 286 | let getValue = () => field.value 287 | 288 | let field = Object.create( 289 | // invisible, not enumerable (interface) 290 | { 291 | valueOf: getValue, 292 | [Symbol.toPrimitive]: getValue, 293 | [Symbol.iterator]: function* () { 294 | yield { ...field } // leave only enumerables (input props) 295 | yield field 296 | } 297 | }, { 298 | // invisible, enumerable (input props) 299 | // FIXME: there's no way to define invisible + enumerable for spread 300 | onBlur: { 301 | enumerable: true, value: e => { 302 | setFocus(field.focus = false) 303 | // revert error to the last value 304 | field.validate() 305 | } 306 | }, 307 | onFocus: { 308 | enumerable: true, value: e => { 309 | field.touched = true 310 | field.error = null 311 | field.valid = true 312 | setFocus(field.focus = true) 313 | } 314 | }, 315 | onChange: { enumerable: true, value: e => field.set(e.target.value) }, 316 | onInput: { enumerable: true, value: e => field.set(e.target.value) }, 317 | 318 | // visible, not enumerable (field state) 319 | error: { enumerable: false, writable: true, value: null }, 320 | valid: { enumerable: false, writable: true, value: true }, 321 | focus: { enumerable: false, writable: true, value: false }, 322 | touched: { enumerable: false, writable: true, value: false }, 323 | set: { 324 | enumerable: false, value: (v) => { 325 | setValue(field.value = v) 326 | if (!field.focus) field.validate() 327 | } 328 | }, 329 | reset: { 330 | enumerable: false, value: () => { 331 | setValue(field.value = init) 332 | field.error = null 333 | field.valid = true 334 | field.touched = false 335 | } 336 | }, 337 | validate: { 338 | enumerable: false, value: (value = field.value) => field.valid = validate(value) 339 | } 340 | }) 341 | 342 | // visible, enumerable (field state + input props) 343 | Object.assign(field, { 344 | value: init, 345 | required, 346 | ref: inputRef, 347 | }) 348 | 349 | return field 350 | }, []) 351 | 352 | // sync error with useValidate, recover from focus as well 353 | hooks.useMemo(() => { 354 | if (!field.focus) field.error = error 355 | }, [error, field.focus]) 356 | 357 | // update input props whenever they change 358 | hooks.useMemo(() => { 359 | Object.assign(field, inputProps) 360 | }, Object.keys(inputProps).map(key => inputProps[key])) 361 | 362 | return field 363 | } 364 | 365 | export function useInput(ref, init) { 366 | if (ref.nodeType) ref = { current: ref } 367 | 368 | let key = '__uhx:input-' + (ref.current && `${ref.current.id || ''}:${ref.current.type || 'text'}:${ref.current.name || ''}`) 369 | 370 | let [value, setValue] = useChannel(key, () => { 371 | // init from input 372 | let value = ref.current && ref.current.value 373 | if (value != null) { 374 | if (typeof init === 'function') return init(value) 375 | return value 376 | } 377 | return init 378 | }) 379 | 380 | hooks.useMemo(() => { 381 | // write value if input is rewired 382 | if (init === undefined) return 383 | if (ref.current && ref.current.value == null) { 384 | ref.current.setAttribute('value', ref.current.value = value) 385 | if (value == null) ref.current.removeAttribute(value) 386 | } 387 | }, [ref.current]) 388 | 389 | hooks.useEffect(() => { 390 | // notify listeners, subscribe to input changes 391 | let update = e => setValue(e.target.value) 392 | ref.current.addEventListener('input', update) 393 | ref.current.addEventListener('change', update) 394 | return () => { 395 | ref.current.removeEventListener('input', update) 396 | ref.current.removeEventListener('change', update) 397 | } 398 | }, [ref.current]) 399 | 400 | return [value, (value) => { 401 | setValue(value) 402 | // main write to input 403 | ref.current.setAttribute('value', ref.current.value = value) 404 | if (value == null) ref.current.removeAttribute('value') 405 | }] 406 | } 407 | 408 | export function useObservable(v) { 409 | let [state, setState] = hooks.useState(v()) 410 | hooks.useEffect(() => v(v => setState(v)), []) 411 | return [state, state => v(state)] 412 | } 413 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # unihooks ![experimental](https://img.shields.io/badge/stability-experimental-yellow) [![Build Status](https://travis-ci.org/unihooks/unihooks.svg?branch=master)](https://travis-ci.org/unihooks/unihooks) 2 | 3 | Essential hooks collection for everyday react[1](#user-content-1) projects. 4 | 5 | [![NPM](https://nodei.co/npm/unihooks.png?mini=true)](https://nodei.co/npm/unihooks/) 6 | 7 | 20 | 21 | ## Principles 22 | 23 |
24 | 1. Framework agnostic 25 |
26 | 27 | _Unihooks_ are not bound to react and work with any hooks-enabled framework: 28 | 29 | * [react](https://ghub.io/react) 30 | * [preact](https://ghub.io/preact) 31 | * [haunted](https://ghub.io/haunted) 32 | * [neverland](https://ghub.io/neverland) 33 | * [atomico](https://ghub.io/atomico) 34 | * [fuco](https://ghub.io/fuco) 35 | * [spect](https://ghub.io/spect) 36 | * [augmentor](https://ghub.io/augmentor) 37 | 38 | See [any-hooks](https://ghub.io/any-hooks) for the full list. 39 | 40 | 50 |
51 | 52 |
53 | 2. Unified 54 |
55 | 56 | _Unihooks_ follow `useState` signature for intuitivity. 57 | 58 | ```js 59 | let [ state, actions ] = useValue( target?, init | update? ) 60 | ``` 61 | 62 | 65 |
66 | 67 | 68 |
69 | 3. Essential 70 |
71 | 72 | _Unihooks_ deliver value in reactive context, they're not mere wrappers for native API. Static hooks are avoided. 73 | 74 | ```js 75 | const MyComponent = () => { let ua = useUserAgent() } // ✘ − user agent never changes 76 | const MyComponent = () => { let ua = navigator.userAgent } // ✔ − direct API must be used instead 77 | ``` 78 | 79 | 85 |
86 | 87 | ## Hooks 88 | 89 |
90 | useChannel 91 | 92 | #### `[value, setValue] = useChannel(key, init?, deps?)` 93 | 94 | Global value provider - `useState` with value identified globally by `key`. 95 | Can be used as value store, eg. as application model layer without persistency. Also can be used for intercomponent communication. 96 | 97 | `init` can be a value or a function, and (re)applies if the `key` (or `deps`) changes. 98 | 99 | ```js 100 | import { useChannel } from 'unihooks' 101 | 102 | function Component () { 103 | let [users, setUsers] = useChannel('users', { 104 | data: [], 105 | loading: false, 106 | current: null 107 | }) 108 | 109 | setUsers({ ...users, loading: true }) 110 | 111 | // or as reducer 112 | setUsers(users => { ...users, loading: false }) 113 | } 114 | ``` 115 |
116 | 117 | 118 |
119 | useStorage 120 | 121 | #### `[value, setValue] = useStorage(key, init?, options?)` 122 | 123 | `useChannel` with persistency to local/session storage. Subscribes to `storage` event - updates if storage is changed from another tab. 124 | 125 | ```js 126 | import { useStorage } from 'unihooks' 127 | 128 | function Component1 () { 129 | const [count, setCount] = useStorage('my-count', 1) 130 | } 131 | 132 | function Component2 () { 133 | const [count, setCount] = useStorage('my-count') 134 | // count === 1 135 | 136 | setCount(2) 137 | // (↑ updates Component1 too) 138 | } 139 | 140 | function Component3 () { 141 | const [count, setCount] = useStorage('another-count', (value) => { 142 | // ...initialize value from store 143 | return value 144 | }) 145 | } 146 | ``` 147 | 148 | #### `options` 149 | 150 | * `prefix` - prefix that's added to stored keys. 151 | * `storage` - manually pass session/local/etc storage. 152 | 153 | 154 | Reference: [useStore](https://ghub.io/use-store). 155 | 156 |
157 | 158 |
159 | useAction 160 | 161 | #### `[action] = useAction(key, cb, deps?)` 162 | 163 | Similar to `useChannel`, but used for storing functions. Different from `useChannel` in the same way the `useCallback` is different from `useMemo`. `deps` indicate if value must be reinitialized. 164 | 165 | ```js 166 | function RootComponent() { 167 | useAction('load-content', async (slug, fresh = false) => { 168 | const url = `/content/${slug}` 169 | const cache = fresh ? 'reload' : 'force-cache' 170 | const res = await fetch(url, { cache }) 171 | return await res.text() 172 | }) 173 | } 174 | 175 | function Content ({ slug = '/' }) { 176 | let [content, setContent] = useState() 177 | let [load] = useAction('load-content') 178 | useEffect(() => load().then(setContent), [slug]) 179 | return html` 180 |
${content}
181 | ` 182 | } 183 | ``` 184 | 185 |
186 | 187 |
188 | useSearchParam 189 | 190 | #### `[value, setValue] = useSearchParam(name, init?)` 191 | 192 | Reflect value to `location.search`. `value` is turned to string via [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams). 193 | To serialize objects or arrays, provide `.toString` method or convert manually. 194 | 195 | **NOTE**. Patches `history.push` and `history.replace` to enable `pushstate` and `replacestate` events. 196 | 197 | ```js 198 | function MyComponent () { 199 | let [id, setId] = useSearchParam('id') 200 | } 201 | ``` 202 | 203 |
204 | 205 | 227 | 228 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 285 | 286 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 334 | 335 | 336 |
337 | useCountdown 338 | 339 | #### `[n, reset] = useCountdown(startValue, interval=1000 | schedule?)` 340 | 341 | Countdown value from `startValue` down to `0` with indicated `interval` in ms. Alternatively, a scheduler function can be passed as `schedule` argument, that can be eg. [worker-timers](https://ghub.io/worker-timers)-based implementation. 342 | 343 | ```js 344 | import { useCountdown } from 'unihooks' 345 | import { setInterval, clearInterval } from 'worker-timers' 346 | 347 | const Demo = () => { 348 | const [count, reset] = useCountdown(30, fn => { 349 | let id = setInterval(fn, 1000) 350 | return () => clearInterval(id) 351 | }); 352 | 353 | return `Remains: ${count}s` 354 | }; 355 | ``` 356 |
357 | 358 | 359 |
360 | useValidate 361 | 362 | #### `[error, validate] = useValidate(validator: Function | Array, init? )` 363 | 364 | Provides validation functionality. 365 | 366 | * `validator` is a function or an array of functions `value => error | true ?`. 367 | * `init` is optional initial value to validate. 368 | 369 | ```js 370 | function MyComponent () { 371 | let [usernameError, validateUsername] = useValidate([ 372 | value => !value ? 'Username is required' : true, 373 | value => value.length < 2 ? 'Username must be at least 2 chars long' : true 374 | ]) 375 | 376 | return <> 377 | validateUsername(e.target.value) && handleInputChange(e) } {...inputProps}/> 378 | { usernameError } 379 | 380 | } 381 | ``` 382 |
383 | 384 | 385 |
386 | useFormField 387 | 388 | #### `[props, field] = useFormField( options )` 389 | 390 | Form field state controller. Handles input state and validation. 391 | Useful for organizing controlled inputs or forms, a nice minimal replacement to form hooks libraries. 392 | 393 | ```js 394 | let [props, field] = useFormField({ 395 | name: 'password', 396 | type: 'password', 397 | validate: value => !!value 398 | }) 399 | 400 | // to set new input value 401 | useEffect(() => field.set(newValue)) 402 | 403 | return 404 | ``` 405 | 406 | #### `options` 407 | 408 | * `value` - initial input value. 409 | * `persist = false` - persist input state between sessions. 410 | * `validate` - custom validator for input, modifies `field.error`. See `useValidate`. 411 | * `required` - if value must not be empty. 412 | * `...props` - the rest of props is passed to `props` 413 | 414 | #### `field` 415 | 416 | * `value` - current input value. 417 | * `error` - current validation error. Revalidates on blur, `null` on focus. 418 | * `valid: bool` - is valid value, revalidates on blur. 419 | * `focus: bool` - if input is focused. 420 | * `touched: bool` - if input was focused. 421 | * `set(value)` - set input value. 422 | * `reset()` - reset form state to initial. 423 | * `validate(value)` - force-validate input. 424 | 425 |
426 | 427 |
428 | useInput 429 | 430 | #### `[value, setValue] = useInput( element | ref )` 431 | 432 | Uncontrolled input element hook. Updates if input value changes. 433 | Setting `null` / `undefined` removes attribute from element. 434 | Useful for organizing simple input controllers, for advanced cases see [useFormField](#useFormField). 435 | 436 | ```js 437 | function MyButton() { 438 | let ref = useRef() 439 | let [value, setValue] = useInput(ref) 440 | 441 | useEffect(() => { 442 | // side-effect when value changes 443 | }, [value]) 444 | 445 | return 446 | } 447 | ``` 448 |
449 | 450 | 451 |
452 | useObservable 453 | 454 | #### `[state, setState] = useObservable(observable)` 455 | 456 | Observable as hook. Plug in any [spect/v](https://ghub.io/spect), [observable](https://ghub.io/observable), [mutant](https://ghub.io/mutant), [observ](https://ghub.io/observ) be free. 457 | 458 | ```js 459 | import { v } from 'spect/v' 460 | 461 | const vCount = v(0) 462 | 463 | function MyComponent () { 464 | let [count, setCount] = useObservable(vCount) 465 | 466 | useEffect(() => { 467 | let id = setInterval(() => setCount(count++), 1000) 468 | return () => clearInterval(id) 469 | }, []) 470 | 471 | return <>Count: { count } 472 | } 473 | ``` 474 |
475 | 476 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 |
605 | standard 606 |
607 | 608 | For convenience, unihooks export current framework hooks. To switch hooks, use `setHooks` - the default export. 609 | 610 | ```js 611 | import setHooks, { useState, useEffect } from 'unihooks' 612 | import * as hooks from 'preact/hooks' 613 | 614 | setHooks(hooks) 615 | 616 | function Timer() { 617 | let [count, setCount] = useState(0) 618 | useEffect(() => { 619 | let id = setInterval(() => setCount(c => ++c)) 620 | return () => clearInterval(id) 621 | }, []) 622 | } 623 | ``` 624 | 625 |
626 | 627 |
628 | utility 629 |
630 | 631 | Utility hooks, useful for high-order-hooks. 632 | 633 | #### `update = useUpdate()` 634 | 635 | Force-update component, regardless of internal state. 636 | 637 | #### `prev = usePrevious(value)` 638 | 639 | Returns the previous state as described in the [React hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state). 640 | 641 | 647 | 648 |
649 | 650 | ## See also 651 | 652 | * [any-hooks](https://ghub.io/any-hooks) - cross-framework standard hooks provider. 653 | * [enhook](https://ghub.io/enhook) - run hooks in regular functions. 654 | 655 | ## Alternatives 656 | 657 | * [valtio](https://github.com/pmndrs/valtio) 658 | 659 | ## License 660 | 661 | MIT 662 | 663 |

HK

664 | --------------------------------------------------------------------------------