├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.cjs ├── bench ├── README.md ├── layers.mjs ├── package-lock.json ├── package.json └── pkgs.cjs ├── config ├── common.js ├── rollup.classes.config.js ├── rollup.config.js ├── rollup.handlers.config.js ├── rollup.http.config.js ├── rollup.react.config.js └── rollup.websocket.config.js ├── docs ├── bench.png ├── index.css ├── mwe │ └── react.html ├── paint │ ├── index.html │ └── paint.css └── todos │ ├── index.html │ ├── index.js │ └── todos.css ├── http ├── index.js └── index.js.map ├── logo.svg ├── package.json ├── pnpm-lock.yaml ├── src ├── batcher.js ├── classes │ ├── README.md │ └── index.js ├── computed.js ├── data.js ├── dispose.js ├── handlers │ ├── README.md │ ├── all.js │ ├── debug.js │ ├── index.js │ └── write.js ├── http │ ├── README.md │ ├── index.js │ ├── normalized.js │ ├── request.js │ ├── resource.js │ └── tools.js ├── index.js ├── observe.js ├── react │ ├── README.md │ ├── assets │ │ └── counter.gif │ ├── context │ │ └── index.js │ ├── hooks │ │ ├── context.js │ │ ├── dependencies.js │ │ ├── index.js │ │ ├── useNormalizedRequest.js │ │ ├── useRequest.js │ │ └── useResource.js │ ├── index.js │ ├── watchComponent.js │ └── watchHoc.js ├── tools.js └── websocket │ ├── README.md │ ├── browser.js │ └── server.js ├── test ├── classes.test.js ├── environment.js ├── handlers.test.js ├── http.test.js ├── index.test.js ├── react │ ├── __snapshots__ │ │ └── context.test.js.snap │ ├── components.test.js │ ├── context.test.js │ ├── hooks.test.js │ └── utils.js └── websocket.test.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /test/hyperactiv.js 2 | /dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "ecmaVersion": 8, 5 | "sourceType": "module" 6 | }, 7 | "env": { 8 | "es6": true, 9 | "browser": true, 10 | "node": true, 11 | "jest/globals": true 12 | }, 13 | "plugins": [ 14 | "jest" 15 | ], 16 | "extends": [ 17 | "eslint:recommended", 18 | "plugin:react/recommended" 19 | ], 20 | "settings": { 21 | "react": { 22 | "version": "detect" 23 | } 24 | }, 25 | "rules": { 26 | "no-console": [ 27 | "warn" 28 | ], 29 | "no-extra-parens": [ 30 | "warn", 31 | "all" 32 | ], 33 | "block-spacing": [ 34 | "warn", 35 | "always" 36 | ], 37 | "brace-style": [ 38 | "warn", 39 | "1tbs", 40 | { 41 | "allowSingleLine": true 42 | } 43 | ], 44 | "camelcase": [ 45 | "error", 46 | { 47 | "properties": "never" 48 | } 49 | ], 50 | "comma-dangle": [ 51 | "error", 52 | "never" 53 | ], 54 | "comma-spacing": [ 55 | "error" 56 | ], 57 | "comma-style": [ 58 | "error" 59 | ], 60 | "computed-property-spacing": [ 61 | "error" 62 | ], 63 | "consistent-this": [ 64 | "error" 65 | ], 66 | "no-trailing-spaces": [ 67 | "error" 68 | ], 69 | "no-multiple-empty-lines": [ 70 | "error" 71 | ], 72 | "func-call-spacing": [ 73 | "error" 74 | ], 75 | "indent": [ 76 | "error", 77 | 2, 78 | { 79 | "flatTernaryExpressions": true, 80 | "SwitchCase": 1 81 | } 82 | ], 83 | "key-spacing": [ 84 | "error", 85 | { 86 | "mode": "minimum" 87 | } 88 | ], 89 | "keyword-spacing": [ 90 | "error", 91 | { 92 | "overrides": { 93 | "if": { 94 | "after": false 95 | }, 96 | "for": { 97 | "after": false 98 | }, 99 | "while": { 100 | "after": false 101 | }, 102 | "catch": { 103 | "after": false 104 | } 105 | } 106 | } 107 | ], 108 | "linebreak-style": [ 109 | "error" 110 | ], 111 | "new-cap": [ 112 | "warn" 113 | ], 114 | "new-parens": [ 115 | "error" 116 | ], 117 | "newline-per-chained-call": [ 118 | "error", 119 | { 120 | "ignoreChainWithDepth": 3 121 | } 122 | ], 123 | "no-lonely-if": [ 124 | "error" 125 | ], 126 | "no-mixed-spaces-and-tabs": [ 127 | "warn", 128 | "smart-tabs" 129 | ], 130 | "no-unneeded-ternary": [ 131 | "error" 132 | ], 133 | "no-whitespace-before-property": [ 134 | "error" 135 | ], 136 | "operator-linebreak": [ 137 | "warn", 138 | "after" 139 | ], 140 | "quote-props": [ 141 | "error", 142 | "as-needed", 143 | { 144 | "keywords": true 145 | } 146 | ], 147 | "quotes": [ 148 | "error", 149 | "single", 150 | { 151 | "avoidEscape": true 152 | } 153 | ], 154 | "no-unexpected-multiline": [ 155 | "warn" 156 | ], 157 | "semi": [ 158 | "error", 159 | "never" 160 | ], 161 | "space-before-blocks": [ 162 | "error" 163 | ], 164 | "space-before-function-paren": [ 165 | "error", 166 | { 167 | "anonymous": "never", 168 | "named": "never", 169 | "asyncArrow": "always" 170 | } 171 | ], 172 | "space-in-parens": [ 173 | "error" 174 | ], 175 | "space-infix-ops": [ 176 | "error", 177 | { 178 | "int32Hint": false 179 | } 180 | ], 181 | "spaced-comment": [ 182 | "error", 183 | "always" 184 | ], 185 | "space-unary-ops": [ 186 | "error", 187 | { 188 | "words": true, 189 | "nonwords": false 190 | } 191 | ], 192 | "unicode-bom": [ 193 | "error", 194 | "never" 195 | ], 196 | "arrow-body-style": [ 197 | "error", 198 | "as-needed" 199 | ], 200 | "arrow-parens": [ 201 | "error", 202 | "as-needed" 203 | ], 204 | "arrow-spacing": [ 205 | "error" 206 | ], 207 | "prefer-const": [ 208 | "error" 209 | ], 210 | "prefer-rest-params": [ 211 | "warn" 212 | ], 213 | "react/display-name": 0, 214 | "react/prop-types": 0 215 | } 216 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: elbywan 2 | custom: ["https://www.paypal.me/elbywan"] 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "**" 7 | push: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | ci: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: latest 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: "18" 23 | cache: "pnpm" 24 | - name: Install dependencies 25 | run: pnpm install --frozen-lockfile 26 | - name: Build 27 | run: pnpm build 28 | - name: Unit tests 29 | run: pnpm test 30 | - name: Coveralls 31 | if: ${{ success() }} 32 | uses: coverallsapp/github-action@master 33 | with: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | path-to-lcov: "./coverage/lcov.info" 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Build 3 | /dist 4 | /types 5 | /temp 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # IDE 67 | .vscode 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Julien Elbaz 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 | Hyperactiv logo 3 |
4 | Hyperactiv
5 | npm-badge 6 | ci-badge 7 | Coverage Status 8 | 9 | license-badge 10 |

11 | 12 |

13 | A super tiny reactive library. ⚡️
14 |
15 |

16 | 17 | ## Description 18 | 19 | Hyperactiv is a super small (~ 1kb minzipped) library which **observes** object mutations and **computes** functions depending on those changes. 20 | 21 | In other terms whenever a property from an observed object is **mutated**, every function that **depend** on this property are **called** right away. 22 | 23 | Of course, Hyperactiv **automatically** handles these dependencies so you **never** have to explicitly declare anything. ✨ 24 | 25 | ---- 26 | 27 | #### Minimal working example 28 | 29 | ```js 30 | import hyperactiv from 'hyperactiv' 31 | const { observe, computed } = hyperactiv 32 | 33 | // This object is observed. 34 | const observed = observe({ 35 | a: 1, 36 | b: 2, 37 | c: 0 38 | }) 39 | 40 | // Calling computed(...) runs the function and memorize its dependencies. 41 | // Here, the function depends on properties 'a' and 'b'. 42 | computed(() => { 43 | const { a, b } = observed 44 | console.log(`a + b = ${a + b}`) 45 | }) 46 | // Prints: a + b = 3 47 | 48 | // Whenever properties 'a' or 'b' are mutated… 49 | observed.a = 2 50 | // The function will automagically be called. 51 | // Prints: a + b = 4 52 | 53 | observed.b = 3 54 | // Prints: a + b = 5 55 | 56 | observed.c = 1 57 | // Nothing depends on 'c', so nothing will happen. 58 | ``` 59 | 60 | ## Demo 61 | 62 | **[Paint demo](https://elbywan.github.io/hyperactiv/paint)** 63 | 64 | **[React store demo](https://elbywan.github.io/hyperactiv/todos)** 65 | 66 | **[React hooks demo](https://github.com/elbywan/hyperactiv-hooks-demo)** 67 | 68 | ## Setup 69 | 70 | ```bash 71 | npm i hyperactiv 72 | ``` 73 | 74 | ```html 75 | 76 | ``` 77 | 78 | ## Import 79 | 80 | **Hyperactiv is bundled as an UMD package.** 81 | 82 | ```js 83 | // ESModules 84 | import hyperactiv from 'hyperactiv' 85 | ``` 86 | 87 | ```js 88 | // Commonjs 89 | const hyperactiv = require('hyperactiv') 90 | ``` 91 | 92 | ```js 93 | // Global variable 94 | const { computed, observe, dispose } = hyperactiv 95 | ``` 96 | 97 | ## Usage 98 | 99 | #### 1. Observe object and arrays 100 | 101 | ```js 102 | const object = observe({ one: 1, two: 2 }) 103 | const array = observe([ 3, 4, 5 ]) 104 | ``` 105 | 106 | #### 2. Define computed functions 107 | 108 | ```js 109 | let sum = 0 110 | 111 | // This function calculates the sum of all elements, 112 | // which is 1 + 2 + 3 + 4 + 5 = 15 at this point. 113 | const calculateSum = computed(() => { 114 | sum = [ 115 | ...Object.values(object), 116 | ...array 117 | ].reduce((acc, curr) => acc + curr) 118 | }) 119 | 120 | // A computed function is called when declared. 121 | console.log(sum) // -> 15 122 | ``` 123 | 124 | #### 3. Mutate observed properties 125 | 126 | ```js 127 | // calculateSum will be called each time one of its dependencies has changed. 128 | 129 | object.one = 2 130 | console.log(sum) // -> 16 131 | array[0]++ 132 | console.log(sum) // -> 17 133 | 134 | array.unshift(1) 135 | console.log(sum) // -> 18 136 | array.shift() 137 | console.log(sum) // -> 17 138 | ``` 139 | 140 | #### 4. Release computed functions 141 | 142 | ```js 143 | // Observed objects store computed function references in a Set, 144 | // which prevents garbage collection as long as the object lives. 145 | // Calling dispose allows the function to be garbage collected. 146 | dispose(calculateSum) 147 | ``` 148 | 149 | ## Add-ons 150 | 151 | #### Additional features that you can import from a sub path. 152 | 153 | - **[hyperactiv/react](https://github.com/elbywan/hyperactiv/tree/master/src/react)** 154 | 155 | *A simple but clever react store.* 156 | 157 | - **[hyperactiv/http](https://github.com/elbywan/hyperactiv/tree/master/src/http)** 158 | 159 | *A reactive http cache.* 160 | 161 | - **[hyperactiv/handlers](https://github.com/elbywan/hyperactiv/tree/master/src/handlers)** 162 | 163 | *Utility callbacks triggered when a property is mutated.* 164 | 165 | - **[hyperactiv/classes](https://github.com/elbywan/hyperactiv/tree/master/src/classes)** 166 | 167 | *An Observable class.* 168 | 169 | - **[hyperactiv/websocket](https://github.com/elbywan/hyperactiv/tree/master/src/websocket)** 170 | 171 | *Hyperactiv websocket implementation.* 172 | 173 | ## Performance 174 | 175 | This repository includes a [benchmark folder](https://github.com/elbywan/hyperactiv/tree/master/bench) which pits `hyperactiv` against other libraries. 176 | 177 | **Important: the benchmarked libraries are not equivalent in terms of features, flexibility and developer friendliness.** 178 | 179 | While not the best in terms of raw performance `hyperactiv` is still reasonably fast and I encourage you to have a look at the different implementations to compare the library APIs. [For instance there is no `.get()` and `.set()` wrappers when using `hyperactiv`](https://github.com/elbywan/hyperactiv/blob/master/bench/layers.mjs#L361). 180 | 181 | **Here are the raw results: _(100 runs per tiers, average time ignoring the 10 best & 10 worst runs)_** 182 | 183 | ![bench](./docs/bench.png) 184 | 185 | > Each tier nests observable objects X (10/100/500/1000…) times and performs some computations on the deepest one. This causes reactions to propagate to the whole observable tree. 186 | 187 | 188 | _**Disclaimer**: I adapted the code from [`maverickjs`](https://github.com/maverick-js/observables/tree/main/bench) which was itself a rewrite of the benchmark from [`cellx`](https://github.com/Riim/cellx#benchmark). I also wrote some MobX code which might not be the best in terms of optimization since I am not very familiar with the API._ 189 | 190 | ## Code samples 191 | 192 | #### A simple sum and a counter 193 | 194 | ```js 195 | // Observe an object and its properties. 196 | const obj = observe({ 197 | a: 1, 198 | b: 2, 199 | sum: 0, 200 | counter: 0 201 | }) 202 | 203 | // The computed function auto-runs by default. 204 | computed(() => { 205 | // This function depends on a, b and counter. 206 | obj.sum = obj.a + obj.b 207 | // It also sets the value of counter, which is circular (get & set). 208 | obj.counter++ 209 | }) 210 | 211 | // The function gets executed when computed() is called… 212 | console.log(obj.sum) // -> 3 213 | console.log(obj.counter) // -> 1 214 | obj.a = 2 215 | // …and when a or b are mutated. 216 | console.log(obj.sum) // -> 4 217 | console.log(obj.counter) // -> 2 218 | obj.b = 3 219 | console.log(obj.sum) // -> 5 220 | console.log(obj.counter) // -> 3 221 | ``` 222 | 223 | #### Nested functions 224 | 225 | ```js 226 | const obj = observe({ 227 | a: 1, 228 | b: 2, 229 | c: 3, 230 | d: 4, 231 | totalSum: 0 232 | }) 233 | 234 | const aPlusB = () => { 235 | return obj.a + obj.b 236 | } 237 | const cPlusD = () => { 238 | return obj.c + obj.d 239 | } 240 | 241 | // Depends on a, b, c and d. 242 | computed(() => { 243 | obj.totalSum = aPlusB() + cPlusD() 244 | }) 245 | 246 | console.log(obj.totalSum) // -> 10 247 | obj.a = 2 248 | console.log(obj.totalSum) // -> 11 249 | obj.d = 5 250 | console.log(obj.totalSum) // -> 12 251 | ``` 252 | 253 | #### Chaining computed properties 254 | 255 | ```js 256 | const obj = observe({ 257 | a: 0, 258 | b: 0, 259 | c: 0, 260 | d: 0 261 | }) 262 | 263 | computed(() => { obj.b = obj.a * 2 }) 264 | computed(() => { obj.c = obj.b * 2 }) 265 | computed(() => { obj.d = obj.c * 2 }) 266 | 267 | obj.a = 10 268 | console.log(obj.d) // -> 80 269 | ``` 270 | 271 | #### Asynchronous computations 272 | 273 | ```js 274 | // Promisified setTimeout. 275 | const delay = time => new Promise(resolve => setTimeout(resolve, time)) 276 | 277 | const obj = observe({ a: 0, b: 0, c: 0 }) 278 | const multiply = () => { 279 | obj.c = obj.a * obj.b 280 | } 281 | const delayedMultiply = computed( 282 | 283 | // When dealing with asynchronous functions 284 | // wrapping with computeAsync is essential to monitor dependencies. 285 | 286 | ({ computeAsync }) => 287 | delay(100).then(() => 288 | computeAsync(multiply)), 289 | { autoRun: false } 290 | ) 291 | 292 | delayedMultiply().then(() => { 293 | console.log(obj.b) // -> 0 294 | obj.a = 2 295 | obj.b = 2 296 | console.log(obj.c) // -> 0 297 | return delay(200) 298 | }).then(() => { 299 | console.log(obj.c) // -> 4 300 | }) 301 | ``` 302 | 303 | #### Batch computations 304 | 305 | ```js 306 | // Promisified setTimeout. 307 | const delay = time => new Promise(resolve => setTimeout(resolve, time)) 308 | 309 | // Enable batch mode. 310 | const array = observe([0, 0, 0], { batch: true }) 311 | 312 | let sum = 0 313 | let triggerCount = 0 314 | 315 | const doSum = computed(() => { 316 | ++triggerCount 317 | sum = array.reduce((acc, curr) => acc + curr) 318 | }) 319 | 320 | console.log(sum) // -> 0 321 | 322 | // Even if we are mutating 3 properties, doSum will only be called once asynchronously. 323 | 324 | array[0] = 1 325 | array[1] = 2 326 | array[2] = 3 327 | 328 | console.log(sum) // -> 0 329 | 330 | delay(10).then(() => { 331 | console.log(`doSum triggered ${triggerCount} time(s).`) // -> doSum triggered 2 time(s). 332 | console.log(sum) // -> 6 333 | }) 334 | ``` 335 | 336 | #### Observe only some properties 337 | 338 | ```js 339 | const object = { 340 | a: 0, 341 | b: 0, 342 | sum: 0 343 | } 344 | 345 | // Use props to observe only some properties 346 | // observeA reacts only when mutating 'a'. 347 | 348 | const observeA = observe(object, { props: ['a'] }) 349 | 350 | // Use ignore to ignore some properties 351 | // observeB reacts only when mutating 'b'. 352 | 353 | const observeB = observe(object, { ignore: ['a', 'sum'] }) 354 | 355 | const doSum = computed(function() { 356 | observeA.sum = observeA.a + observeB.b 357 | }) 358 | 359 | // Triggers doSum. 360 | 361 | observeA.a = 2 362 | console.log(object.sum) // -> 2 363 | 364 | // Does not trigger doSum. 365 | 366 | observeA.b = 1 367 | observeB.a = 1 368 | console.log(object.sum) // -> 2 369 | 370 | // Triggers doSum. 371 | 372 | observeB.b = 2 373 | console.log(object.sum) // -> 3 374 | ``` 375 | 376 | #### Automatically bind methods 377 | 378 | ```javascript 379 | let obj = new SomeClass() 380 | obj = observe(obj, { bind: true }) 381 | obj.someMethodThatMutatesObjUsingThis() 382 | // observe sees all! 383 | ``` 384 | 385 | #### This and class syntaxes 386 | 387 | ```js 388 | class MyClass { 389 | constructor() { 390 | this.a = 1 391 | this.b = 2 392 | 393 | const _this = observe(this) 394 | 395 | // Bind computed functions to the observed instance. 396 | this.doSum = computed(this.doSum.bind(_this)) 397 | 398 | // Return an observed instance. 399 | return _this 400 | } 401 | 402 | doSum() { 403 | this.sum = this.a + this.b 404 | } 405 | } 406 | 407 | const obj = new MyClass() 408 | console.log(obj.sum) // -> 3 409 | obj.a = 2 410 | console.log(obj.sum) // -> 4 411 | ``` 412 | 413 | ```js 414 | const obj = observe({ 415 | a: 1, 416 | b: 2, 417 | doSum: function() { 418 | this.sum = this.a + this.b 419 | } 420 | }, { 421 | // Use the bind flag to bind doSum to the observed object. 422 | bind: true 423 | }) 424 | 425 | obj.doSum = computed(obj.doSum) 426 | console.log(obj.sum) // -> 3 427 | obj.a = 2 428 | console.log(obj.sum) // -> 4 429 | ``` 430 | 431 | ## API 432 | 433 | ### observe 434 | 435 | Observes an object or an array and returns a proxified version which reacts on mutations. 436 | 437 | ```ts 438 | observe(Object | Array, { 439 | props: String[], 440 | ignore: String[], 441 | batch: boolean, 442 | deep: boolean = true, 443 | bind: boolean 444 | }) => Proxy 445 | ``` 446 | 447 | **Options** 448 | 449 | - `props: String[]` 450 | 451 | Observe only the properties listed. 452 | 453 | - `ignore: String[]` 454 | 455 | Ignore the properties listed. 456 | 457 | - `batch: boolean | int` 458 | 459 | Batch computed properties calls, wrapping them in a setTimeout and executing them in a new context and preventing excessive calls. 460 | If batch is an integer greater than zero, the calls will be debounced by the value in milliseconds. 461 | 462 | - `deep: boolean` 463 | 464 | Recursively observe nested objects and when setting new properties. 465 | 466 | - `bind: boolean` 467 | 468 | Automatically bind methods to the observed object. 469 | 470 | ### computed 471 | 472 | Wraps a function and captures observed properties which are accessed during the function execution. 473 | When those properties are mutated, the function is called to reflect the changes. 474 | 475 | ```ts 476 | computed(fun: Function, { 477 | autoRun: boolean, 478 | callback: Function 479 | }) => Proxy 480 | ``` 481 | 482 | **Options** 483 | 484 | - `autoRun: boolean` 485 | 486 | If false, will not run the function argument when calling `computed(function)`. 487 | 488 | The computed function **must** be called **at least once** to calculate its dependencies. 489 | 490 | - `callback: Function` 491 | 492 | Specify a callback that will be re-runned each time a dependency changes instead of the computed function. 493 | 494 | ### dispose 495 | 496 | Will remove the computed function from the reactive Maps (the next time an bound observer property is called) allowing garbage collection. 497 | 498 | ```ts 499 | dispose(Function) => void 500 | ``` 501 | 502 | ### batch 503 | 504 | _Only when observables are created with the `{batch: …}` flag_ 505 | 506 | Will perform accumulated b.ed computations instantly. 507 | 508 | ```ts 509 | const obj = observe({ a: 0, b: 0 }, { batch: true }) 510 | computed(() => obj.a = obj.b) 511 | obj.b++ 512 | obj.b++ 513 | console.log(obj.a) // => 0 514 | batch() 515 | console.log(obj.a) // => 2 516 | ``` 517 | 518 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ], 11 | '@babel/preset-react' 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | ## Benchmark 2 | 3 | _Adapted from: https://github.com/maverick-js/observables/tree/main/bench_ 4 | 5 | ```bash 6 | npm i 7 | env NODE_OPTIONS="--max-old-space-size=8096" npm start 8 | ``` 9 | -------------------------------------------------------------------------------- /bench/layers.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | /* eslint-disable no-console */ 3 | /** 4 | * Adapted from: https://github.com/maverick-js/observables/tree/main/bench 5 | */ 6 | 7 | 8 | import kleur from 'kleur' 9 | import * as cellx from 'cellx' 10 | import * as Sjs from 's-js' 11 | import * as mobx from 'mobx' 12 | import * as maverick from '@maverick-js/observables' 13 | import * as preact from '@preact/signals-core' 14 | import hyperactiv from 'hyperactiv' 15 | import Table from 'cli-table' 16 | import pkgs from './pkgs.cjs' 17 | import v8 from 'node:v8' 18 | import vm from 'vm' 19 | 20 | v8.setFlagsFromString('--expose-gc') 21 | const gc = vm.runInNewContext('gc') 22 | function collectGarbage() { 23 | gc && gc() 24 | } 25 | 26 | const RUNS_PER_TIER = 100 27 | const DISCARD_BEST_WORST_X_RUNS = 10 28 | const LAYER_TIERS = [10, 100, 500, 1000, 2000, 2500/* , 5000, 10000*/] 29 | 30 | const sum = array => array.reduce((a, b) => a + b, 0) 31 | const avg = array => sum(array) / array.length || 0 32 | 33 | const SOLUTIONS = { 34 | 10: [2, 4, -2, -3], 35 | 100: [-2, -4, 2, 3], 36 | 500: [-2, 1, -4, -4], 37 | 1000: [-2, -4, 2, 3], 38 | 2000: [-2, 1, -4, -4], 39 | 2500: [-2, -4, 2, 3], 40 | 5000: [-2, 1, -4, -4], 41 | 10000: [ -2, -4, 2, 3 ] 42 | } 43 | 44 | /** 45 | * @param {number} layers 46 | * @param {number[]} answer 47 | */ 48 | const isSolution = (layers, answer) => answer.every((s, i) => s === SOLUTIONS[layers][i]) 49 | 50 | const pkgKey = pkg => `${pkgs[pkg].name}@${pkgs[pkg].version}` 51 | 52 | async function main() { 53 | const report = { 54 | [pkgKey('cellx')]: { fn: runCellx, runs: [] }, 55 | [pkgKey('hyperactiv')]: { fn: runHyperactiv, runs: [] }, 56 | [pkgKey('maverick')]: { fn: runMaverick, runs: [], avg: [] }, 57 | [pkgKey('mobx')]: { fn: runMobx, runs: [] }, 58 | [pkgKey('preact')]: { fn: runPreact, runs: [] }, 59 | [pkgKey('S')]: { fn: runS, runs: [] } 60 | } 61 | 62 | for(const lib of Object.keys(report)) { 63 | // Force garbage collection when switching libraries 64 | collectGarbage() 65 | 66 | const current = report[lib] 67 | 68 | for(let i = 0; i < LAYER_TIERS.length; i += 1) { 69 | const layers = LAYER_TIERS[i] 70 | let runs = [] 71 | let result = null 72 | 73 | for(let j = 0; j < RUNS_PER_TIER; j += 1) { 74 | result = await start(current.fn, layers) 75 | if(typeof result !== 'number') { 76 | break 77 | } 78 | runs.push(await start(current.fn, layers)) 79 | } 80 | // Allow libraries that free resources asynchronously (e.g. cellx) do so. 81 | await new Promise(resolve => setTimeout(resolve, 0)) 82 | 83 | if(typeof result !== 'number') { 84 | current.runs[i] = result 85 | } else { 86 | if(DISCARD_BEST_WORST_X_RUNS) { 87 | runs = runs.sort().slice(DISCARD_BEST_WORST_X_RUNS, -DISCARD_BEST_WORST_X_RUNS) 88 | } 89 | current.runs[i] = avg(runs) * 1000 90 | } 91 | } 92 | } 93 | 94 | const table = new Table({ 95 | head: ['', ...LAYER_TIERS.map(n => kleur.bold(kleur.cyan(n)))] 96 | }) 97 | 98 | for(let i = 0; i < LAYER_TIERS.length; i += 1) { 99 | let min = Infinity, 100 | max = -1, 101 | fastestLib, 102 | slowestLib 103 | 104 | 105 | for(const lib of Object.keys(report)) { 106 | const time = report[lib].runs[i] 107 | 108 | if(typeof time !== 'number') { 109 | continue 110 | } 111 | 112 | if(time < min) { 113 | min = time 114 | fastestLib = lib 115 | } 116 | 117 | if(time > max) { 118 | max = time 119 | slowestLib = lib 120 | } 121 | } 122 | 123 | if(fastestLib && typeof report[fastestLib].runs[i] === 'number') 124 | report[fastestLib].runs[i] = kleur.green(report[fastestLib].runs[i].toFixed(2)) 125 | if(slowestLib && typeof report[slowestLib].runs[i] === 'number') 126 | report[slowestLib].runs[i] = kleur.red(report[slowestLib].runs[i].toFixed(2)) 127 | } 128 | 129 | for(const lib of Object.keys(report)) { 130 | table.push([ 131 | kleur.magenta(lib), 132 | ...report[lib].runs.map(n => typeof n === 'number' ? n.toFixed(2) : n) 133 | ]) 134 | } 135 | 136 | console.log(table.toString()) 137 | } 138 | 139 | async function start(runner, layers) { 140 | return new Promise(done => { 141 | runner(layers, done) 142 | }).catch(error => { 143 | console.error(error) 144 | return error.message.toString() 145 | }) 146 | } 147 | 148 | /** 149 | * @see {@link https://github.com/Riim/cellx} 150 | */ 151 | function runCellx(layers, done) { 152 | const start = { 153 | a: new cellx.Cell(1), 154 | b: new cellx.Cell(2), 155 | c: new cellx.Cell(3), 156 | d: new cellx.Cell(4) 157 | } 158 | 159 | let layer = start 160 | 161 | for(let i = layers; i--;) { 162 | layer = (m => { 163 | const props = { 164 | a: new cellx.Cell(() => m.b.get()), 165 | b: new cellx.Cell(() => m.a.get() - m.c.get()), 166 | c: new cellx.Cell(() => m.b.get() + m.d.get()), 167 | d: new cellx.Cell(() => m.c.get()) 168 | } 169 | 170 | props.a.on('change', function() { }) 171 | props.b.on('change', function() { }) 172 | props.c.on('change', function() { }) 173 | props.d.on('change', function() { }) 174 | 175 | return props 176 | })(layer) 177 | } 178 | 179 | const startTime = performance.now() 180 | const end = layer 181 | 182 | start.a.set(4) 183 | start.b.set(3) 184 | start.c.set(2) 185 | start.d.set(1) 186 | 187 | const solution = [end.a.get(), end.b.get(), end.c.get(), end.d.get()] 188 | const endTime = performance.now() - startTime 189 | 190 | start.a.dispose() 191 | start.b.dispose() 192 | start.c.dispose() 193 | start.d.dispose() 194 | 195 | done(isSolution(layers, solution) ? endTime : 'wrong') 196 | } 197 | 198 | /** 199 | * @see {@link https://github.com/maverick-js/observables} 200 | */ 201 | function runMaverick(layers, done) { 202 | maverick.root(dispose => { 203 | const start = { 204 | a: maverick.observable(1), 205 | b: maverick.observable(2), 206 | c: maverick.observable(3), 207 | d: maverick.observable(4) 208 | } 209 | 210 | let layer = start 211 | 212 | for(let i = layers; i--;) { 213 | layer = (m => ({ 214 | a: maverick.computed(() => m.b()), 215 | b: maverick.computed(() => m.a() - m.c()), 216 | c: maverick.computed(() => m.b() + m.d()), 217 | d: maverick.computed(() => m.c()) 218 | }))(layer) 219 | } 220 | 221 | const startTime = performance.now() 222 | const end = layer 223 | 224 | start.a.set(4), start.b.set(3), start.c.set(2), start.d.set(1) 225 | 226 | const solution = [end.a(), end.b(), end.c(), end.d()] 227 | const endTime = performance.now() - startTime 228 | 229 | dispose() 230 | done(isSolution(layers, solution) ? endTime : 'wrong') 231 | }) 232 | } 233 | 234 | /** 235 | * @see {@link https://github.com/adamhaile/S} 236 | */ 237 | function runS(layers, done) { 238 | const S = Sjs.default 239 | 240 | S.root(() => { 241 | const start = { 242 | a: S.data(1), 243 | b: S.data(2), 244 | c: S.data(3), 245 | d: S.data(4) 246 | } 247 | 248 | let layer = start 249 | 250 | for(let i = layers; i--;) { 251 | layer = (m => { 252 | const props = { 253 | a: S(() => m.b()), 254 | b: S(() => m.a() - m.c()), 255 | c: S(() => m.b() + m.d()), 256 | d: S(() => m.c()) 257 | } 258 | 259 | return props 260 | })(layer) 261 | } 262 | 263 | const startTime = performance.now() 264 | const end = layer 265 | 266 | start.a(4), start.b(3), start.c(2), start.d(1) 267 | 268 | const solution = [end.a(), end.b(), end.c(), end.d()] 269 | const endTime = performance.now() - startTime 270 | 271 | done(isSolution(layers, solution) ? endTime : 'wrong') 272 | }) 273 | } 274 | 275 | /** 276 | * @see {@link https://github.com/mobxjs/mobx} 277 | */ 278 | function runMobx(layers, done) { 279 | mobx.configure({ 280 | enforceActions: 'never' 281 | }) 282 | const start = mobx.observable({ 283 | a: mobx.observable(1), 284 | b: mobx.observable(2), 285 | c: mobx.observable(3), 286 | d: mobx.observable(4) 287 | }) 288 | let layer = start 289 | 290 | for(let i = layers; i--;) { 291 | layer = (prev => { 292 | const next = { 293 | a: mobx.computed(() => prev.b.get()), 294 | b: mobx.computed(() => prev.a.get() - prev.c.get()), 295 | c: mobx.computed(() => prev.b.get() + prev.d.get()), 296 | d: mobx.computed(() => prev.c.get()) 297 | } 298 | 299 | return next 300 | })(layer) 301 | } 302 | 303 | const end = layer 304 | 305 | const startTime = performance.now() 306 | 307 | start.a.set(4) 308 | start.b.set(3) 309 | start.c.set(2) 310 | start.d.set(1) 311 | 312 | const solution = [ 313 | end.a.get(), 314 | end.b.get(), 315 | end.c.get(), 316 | end.d.get() 317 | ] 318 | const endTime = performance.now() - startTime 319 | done(isSolution(layers, solution) ? endTime : 'wrong') 320 | } 321 | 322 | /** 323 | * @see {@link https://github.com/preactjs/signals} 324 | */ 325 | function runPreact(layers, done) { 326 | const a = preact.signal(1), 327 | b = preact.signal(2), 328 | c = preact.signal(3), 329 | d = preact.signal(4) 330 | 331 | const start = { a, b, c, d } 332 | 333 | let layer = start 334 | 335 | for(let i = layers; i--;) { 336 | layer = (m => { 337 | const props = { 338 | a: preact.computed(() => m.b.value), 339 | b: preact.computed(() => m.a.value - m.c.value), 340 | c: preact.computed(() => m.b.value + m.d.value), 341 | d: preact.computed(() => m.c.value) 342 | } 343 | 344 | return props 345 | })(layer) 346 | } 347 | 348 | const startTime = performance.now() 349 | const end = layer 350 | 351 | preact.batch(() => { 352 | a.value = 4, b.value = 3, c.value = 2, d.value = 1 353 | }) 354 | 355 | const solution = [end.a.value, end.b.value, end.c.value, end.d.value] 356 | const endTime = performance.now() - startTime 357 | 358 | done(isSolution(layers, solution) ? endTime : -1) 359 | } 360 | 361 | function runHyperactiv(layers, done) { 362 | const observe = obj => hyperactiv.observe(obj, { batch: true }) 363 | const computed = fn => hyperactiv.computed(fn, { disableTracking: true }) 364 | 365 | const start = observe({ 366 | a: 1, 367 | b: 2, 368 | c: 3, 369 | d: 4 370 | }) 371 | let layer = start 372 | 373 | for(let i = layers; i--;) { 374 | layer = (prev => { 375 | const next = observe({}) 376 | computed(() => next.a = prev.b) 377 | computed(() => next.b = prev.a - prev.c) 378 | computed(() => next.c = prev.b + prev.d) 379 | computed(() => next.d = prev.c) 380 | return next 381 | })(layer) 382 | } 383 | 384 | const end = layer 385 | 386 | const startTime = performance.now() 387 | 388 | start.a = 4 389 | start.b = 3 390 | start.c = 2 391 | start.d = 1 392 | 393 | hyperactiv.batch() 394 | 395 | const solution = [ 396 | end.a, 397 | end.b, 398 | end.c, 399 | end.d 400 | ] 401 | const endTime = performance.now() - startTime 402 | done(isSolution(layers, solution) ? endTime : 'wrong') 403 | } 404 | 405 | main() 406 | -------------------------------------------------------------------------------- /bench/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmarks", 3 | "version": "0.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "benchmarks", 9 | "version": "0.0.0", 10 | "dependencies": { 11 | "@maverick-js/observables": "^4.5.0", 12 | "@preact/signals-core": "^1.2.0", 13 | "cellx": "^1.10.29", 14 | "cli-table": "^0.3.11", 15 | "hyperactiv": "file:..", 16 | "kleur": "^4.1.5", 17 | "mobx": "^6.6.2", 18 | "s-js": "^0.4.9" 19 | } 20 | }, 21 | "..": { 22 | "version": "0.11.2", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@babel/core": "^7.17.10", 26 | "@babel/eslint-parser": "^7.17.0", 27 | "@babel/preset-env": "^7.17.10", 28 | "@babel/preset-react": "^7.16.7", 29 | "@testing-library/jest-dom": "^5.16.4", 30 | "@testing-library/react": "^13.2.0", 31 | "@types/jest": "^27.5.0", 32 | "babel-jest": "^28.1.0", 33 | "eslint": "^8.15.0", 34 | "eslint-plugin-jest": "^26.1.5", 35 | "eslint-plugin-react": "^7.29.4", 36 | "jest": "^28.1.0", 37 | "jest-environment-jsdom": "^28.1.0", 38 | "node-fetch": "^2", 39 | "normaliz": "^0.2.0", 40 | "react": "^18.1.0", 41 | "react-dom": "^18.1.0", 42 | "react-test-renderer": "^18.1.0", 43 | "rimraf": "^3.0.2", 44 | "rollup": "^2.72.0", 45 | "rollup-plugin-terser": "^7.0.2", 46 | "typescript": "^4.6.4", 47 | "wretch": "^1.7.9", 48 | "ws": "^7" 49 | }, 50 | "peerDependenciesMeta": { 51 | "normaliz": { 52 | "optional": true 53 | }, 54 | "react": { 55 | "optional": true 56 | }, 57 | "react-dom": { 58 | "optional": true 59 | }, 60 | "wretch": { 61 | "optional": true 62 | } 63 | } 64 | }, 65 | "node_modules/@maverick-js/observables": { 66 | "version": "4.5.0", 67 | "resolved": "https://registry.npmjs.org/@maverick-js/observables/-/observables-4.5.0.tgz", 68 | "integrity": "sha512-aS5NHxkm4w5CDAUuIfzkiqC2GUM5cgVUVaREkg7vb0KgAvw+4IIBRyj400QJ/zAPb/+/7uSR6dO+S+xcU6wA0Q==", 69 | "dependencies": { 70 | "@maverick-js/scheduler": "^2.0.0" 71 | } 72 | }, 73 | "node_modules/@maverick-js/scheduler": { 74 | "version": "2.0.0", 75 | "resolved": "https://registry.npmjs.org/@maverick-js/scheduler/-/scheduler-2.0.0.tgz", 76 | "integrity": "sha512-SDrWbTeRhLVs4vR4CDm0RIkoX/OJ9umjMtYfFbiZJHWu9CpewCNRq2I3MVFFH58/qDWkfF0wFrIGpetJZfbKEg==" 77 | }, 78 | "node_modules/@preact/signals-core": { 79 | "version": "1.2.0", 80 | "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.0.tgz", 81 | "integrity": "sha512-GBjq/8WJkh/aenrEMvWIOr1lRfu6nb25er0m6r+4qVqKC85mXAYIbGHnoHTooNXCKxn2KEMUrATvN54MdMHE1A==", 82 | "funding": { 83 | "type": "opencollective", 84 | "url": "https://opencollective.com/preact" 85 | } 86 | }, 87 | "node_modules/@riim/next-tick": { 88 | "version": "1.2.6", 89 | "resolved": "https://registry.npmjs.org/@riim/next-tick/-/next-tick-1.2.6.tgz", 90 | "integrity": "sha512-Grcu9OhD5Ohk/x2i0DwxB37Xf7ElmdBA5dGSDTNFNyiMPsMwc+XjciH+wgWd6vIq6bEtMPmaSXUm3i9q/TEyag==" 91 | }, 92 | "node_modules/cellx": { 93 | "version": "1.10.29", 94 | "resolved": "https://registry.npmjs.org/cellx/-/cellx-1.10.29.tgz", 95 | "integrity": "sha512-fUdUecXe7UY4dY3il26DYfTl7ugNkGAycB1w5oc/GE0KRHZcT03ZgnKSXt/Xpt/7eYxqSXj0sFA8B/RIEpyH1A==", 96 | "dependencies": { 97 | "@riim/next-tick": "1.2.6" 98 | } 99 | }, 100 | "node_modules/cli-table": { 101 | "version": "0.3.11", 102 | "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", 103 | "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", 104 | "dependencies": { 105 | "colors": "1.0.3" 106 | }, 107 | "engines": { 108 | "node": ">= 0.2.0" 109 | } 110 | }, 111 | "node_modules/colors": { 112 | "version": "1.0.3", 113 | "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", 114 | "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", 115 | "engines": { 116 | "node": ">=0.1.90" 117 | } 118 | }, 119 | "node_modules/hyperactiv": { 120 | "resolved": "..", 121 | "link": true 122 | }, 123 | "node_modules/kleur": { 124 | "version": "4.1.5", 125 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 126 | "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 127 | "engines": { 128 | "node": ">=6" 129 | } 130 | }, 131 | "node_modules/mobx": { 132 | "version": "6.6.2", 133 | "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.6.2.tgz", 134 | "integrity": "sha512-IOpS0bf3+hXIhDIy+CmlNMBfFpAbHS0aVHcNC+xH/TFYEKIIVDKNYRh9eKlXuVfJ1iRKAp0cRVmO145CyJAMVQ==", 135 | "funding": { 136 | "type": "opencollective", 137 | "url": "https://opencollective.com/mobx" 138 | } 139 | }, 140 | "node_modules/s-js": { 141 | "version": "0.4.9", 142 | "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", 143 | "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==" 144 | } 145 | }, 146 | "dependencies": { 147 | "@maverick-js/observables": { 148 | "version": "4.5.0", 149 | "resolved": "https://registry.npmjs.org/@maverick-js/observables/-/observables-4.5.0.tgz", 150 | "integrity": "sha512-aS5NHxkm4w5CDAUuIfzkiqC2GUM5cgVUVaREkg7vb0KgAvw+4IIBRyj400QJ/zAPb/+/7uSR6dO+S+xcU6wA0Q==", 151 | "requires": { 152 | "@maverick-js/scheduler": "^2.0.0" 153 | } 154 | }, 155 | "@maverick-js/scheduler": { 156 | "version": "2.0.0", 157 | "resolved": "https://registry.npmjs.org/@maverick-js/scheduler/-/scheduler-2.0.0.tgz", 158 | "integrity": "sha512-SDrWbTeRhLVs4vR4CDm0RIkoX/OJ9umjMtYfFbiZJHWu9CpewCNRq2I3MVFFH58/qDWkfF0wFrIGpetJZfbKEg==" 159 | }, 160 | "@preact/signals-core": { 161 | "version": "1.2.0", 162 | "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.0.tgz", 163 | "integrity": "sha512-GBjq/8WJkh/aenrEMvWIOr1lRfu6nb25er0m6r+4qVqKC85mXAYIbGHnoHTooNXCKxn2KEMUrATvN54MdMHE1A==" 164 | }, 165 | "@riim/next-tick": { 166 | "version": "1.2.6", 167 | "resolved": "https://registry.npmjs.org/@riim/next-tick/-/next-tick-1.2.6.tgz", 168 | "integrity": "sha512-Grcu9OhD5Ohk/x2i0DwxB37Xf7ElmdBA5dGSDTNFNyiMPsMwc+XjciH+wgWd6vIq6bEtMPmaSXUm3i9q/TEyag==" 169 | }, 170 | "cellx": { 171 | "version": "1.10.29", 172 | "resolved": "https://registry.npmjs.org/cellx/-/cellx-1.10.29.tgz", 173 | "integrity": "sha512-fUdUecXe7UY4dY3il26DYfTl7ugNkGAycB1w5oc/GE0KRHZcT03ZgnKSXt/Xpt/7eYxqSXj0sFA8B/RIEpyH1A==", 174 | "requires": { 175 | "@riim/next-tick": "1.2.6" 176 | } 177 | }, 178 | "cli-table": { 179 | "version": "0.3.11", 180 | "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", 181 | "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", 182 | "requires": { 183 | "colors": "1.0.3" 184 | } 185 | }, 186 | "colors": { 187 | "version": "1.0.3", 188 | "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", 189 | "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==" 190 | }, 191 | "hyperactiv": { 192 | "version": "file:..", 193 | "requires": { 194 | "@babel/core": "^7.17.10", 195 | "@babel/eslint-parser": "^7.17.0", 196 | "@babel/preset-env": "^7.17.10", 197 | "@babel/preset-react": "^7.16.7", 198 | "@testing-library/jest-dom": "^5.16.4", 199 | "@testing-library/react": "^13.2.0", 200 | "@types/jest": "^27.5.0", 201 | "babel-jest": "^28.1.0", 202 | "eslint": "^8.15.0", 203 | "eslint-plugin-jest": "^26.1.5", 204 | "eslint-plugin-react": "^7.29.4", 205 | "jest": "^28.1.0", 206 | "jest-environment-jsdom": "^28.1.0", 207 | "node-fetch": "^2", 208 | "normaliz": "^0.2.0", 209 | "react": "^18.1.0", 210 | "react-dom": "^18.1.0", 211 | "react-test-renderer": "^18.1.0", 212 | "rimraf": "^3.0.2", 213 | "rollup": "^2.72.0", 214 | "rollup-plugin-terser": "^7.0.2", 215 | "typescript": "^4.6.4", 216 | "wretch": "^1.7.9", 217 | "ws": "^7" 218 | } 219 | }, 220 | "kleur": { 221 | "version": "4.1.5", 222 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 223 | "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" 224 | }, 225 | "mobx": { 226 | "version": "6.6.2", 227 | "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.6.2.tgz", 228 | "integrity": "sha512-IOpS0bf3+hXIhDIy+CmlNMBfFpAbHS0aVHcNC+xH/TFYEKIIVDKNYRh9eKlXuVfJ1iRKAp0cRVmO145CyJAMVQ==" 229 | }, 230 | "s-js": { 231 | "version": "0.4.9", 232 | "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", 233 | "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==" 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmarks", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node layers.mjs" 7 | }, 8 | "dependencies": { 9 | "@maverick-js/observables": "^4.5.0", 10 | "@preact/signals-core": "^1.2.0", 11 | "cellx": "^1.10.29", 12 | "cli-table": "^0.3.11", 13 | "hyperactiv": "file:..", 14 | "kleur": "^4.1.5", 15 | "mobx": "^6.6.2", 16 | "s-js": "^0.4.9" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /bench/pkgs.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | 4 | module.exports = { 5 | cellx: require('cellx/package.json'), 6 | hyperactiv: require('hyperactiv/package.json'), 7 | maverick: require('@maverick-js/observables/package.json'), 8 | mobx: require('mobx/package.json'), 9 | S: require('s-js/package.json'), 10 | preact: JSON.parse(fs.readFileSync(path.resolve(require.resolve('@preact/signals-core'), '..', '..', 'package.json').toString())) 11 | } -------------------------------------------------------------------------------- /config/common.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { terser } from 'rollup-plugin-terser' 3 | 4 | export const HYPERACTIV_PATH = path.resolve(__dirname, '../src/index.js') 5 | 6 | export const plugins = [ 7 | terser() 8 | ] 9 | export const sourcemap = true 10 | -------------------------------------------------------------------------------- /config/rollup.classes.config.js: -------------------------------------------------------------------------------- 1 | import { HYPERACTIV_PATH, plugins, sourcemap } from './common' 2 | 3 | export default { 4 | input: './src/classes/index.js', 5 | output: [ 6 | { 7 | file: 'dist/classes/index.js', 8 | format: 'umd', 9 | name: 'hyperactiv-classes', 10 | sourcemap, 11 | globals: { 12 | [HYPERACTIV_PATH]: 'hyperactiv' 13 | }, 14 | paths: { 15 | [HYPERACTIV_PATH]: 'hyperactiv' 16 | } 17 | }, 18 | { 19 | file: 'dist/classes/index.mjs', 20 | format: 'es', 21 | sourcemap, 22 | globals: { 23 | [HYPERACTIV_PATH]: 'hyperactiv' 24 | }, 25 | paths: { 26 | [HYPERACTIV_PATH]: '../index.mjs' 27 | } 28 | } 29 | ], 30 | external: [ 31 | HYPERACTIV_PATH 32 | ], 33 | plugins, 34 | makeAbsoluteExternalsRelative: false 35 | } 36 | -------------------------------------------------------------------------------- /config/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { plugins, sourcemap } from './common' 2 | 3 | export default { 4 | input: './src/index.js', 5 | output: [ 6 | { 7 | file: 'dist/index.js', 8 | format: 'umd', 9 | name: 'hyperactiv', 10 | sourcemap 11 | }, { 12 | file: 'dist/index.mjs', 13 | format: 'es', 14 | sourcemap 15 | } 16 | ], 17 | plugins 18 | } 19 | -------------------------------------------------------------------------------- /config/rollup.handlers.config.js: -------------------------------------------------------------------------------- 1 | import { plugins, sourcemap } from './common' 2 | 3 | export default { 4 | input: './src/handlers/index.js', 5 | output: [ 6 | { 7 | file: 'dist/handlers/index.js', 8 | format: 'umd', 9 | name: 'hyperactiv-handlers', 10 | sourcemap 11 | }, 12 | { 13 | file: 'dist/handlers/index.mjs', 14 | format: 'es', 15 | sourcemap 16 | } 17 | ], 18 | plugins 19 | } 20 | -------------------------------------------------------------------------------- /config/rollup.http.config.js: -------------------------------------------------------------------------------- 1 | import { plugins, sourcemap } from './common' 2 | 3 | export default { 4 | input: './src/http/index.js', 5 | output: [ 6 | { 7 | file: 'dist/http/index.js', 8 | format: 'umd', 9 | name: 'hyperactiv-http', 10 | globals: { 11 | wretch: 'wretch', 12 | normaliz: 'normaliz' 13 | }, 14 | sourcemap 15 | }, { 16 | file: 'dist/http/index.mjs', 17 | format: 'es', 18 | globals: { 19 | wretch: 'wretch', 20 | normaliz: 'normaliz' 21 | }, 22 | sourcemap 23 | } 24 | ], 25 | plugins, 26 | external: [ 27 | 'wretch', 28 | 'normaliz' 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /config/rollup.react.config.js: -------------------------------------------------------------------------------- 1 | import { HYPERACTIV_PATH, plugins, sourcemap } from './common' 2 | 3 | /** 4 | * @type {import('rollup').RollupOptions} 5 | */ 6 | export default { 7 | input: './src/react/index.js', 8 | output: [ 9 | { 10 | file: 'dist/react/index.js', 11 | format: 'umd', 12 | name: 'react-hyperactiv', 13 | globals: { 14 | react: 'React', 15 | 'react-dom': 'ReactDOM', 16 | 'react-dom/server': 'ReactDOMServer', 17 | wretch: 'wretch', 18 | normaliz: 'normaliz', 19 | [HYPERACTIV_PATH]: 'hyperactiv' 20 | }, 21 | paths: { 22 | [HYPERACTIV_PATH]: 'hyperactiv' 23 | }, 24 | sourcemap 25 | }, { 26 | file: 'dist/react/index.mjs', 27 | format: 'es', 28 | globals: { 29 | react: 'React', 30 | 'react-dom': 'ReactDOM', 31 | 'react-dom/server': 'ReactDOMServer', 32 | wretch: 'wretch', 33 | normaliz: 'normaliz', 34 | [HYPERACTIV_PATH]: 'hyperactiv' 35 | }, 36 | paths: { 37 | [HYPERACTIV_PATH]: '../index.mjs' 38 | }, 39 | sourcemap 40 | } 41 | ], 42 | plugins, 43 | external: [ 44 | 'react', 45 | 'react-dom', 46 | 'react-dom/server', 47 | 'wretch', 48 | 'normaliz', 49 | HYPERACTIV_PATH 50 | ], 51 | makeAbsoluteExternalsRelative: false 52 | } 53 | -------------------------------------------------------------------------------- /config/rollup.websocket.config.js: -------------------------------------------------------------------------------- 1 | import { plugins, sourcemap } from './common' 2 | 3 | const serverBuild = { 4 | input: './src/websocket/server.js', 5 | output: [ 6 | { 7 | file: 'dist/websocket/server.js', 8 | format: 'cjs', 9 | name: 'hyperactiv-websocket', 10 | sourcemap, 11 | exports: 'default' 12 | }, 13 | { 14 | file: 'dist/websocket/server.mjs', 15 | format: 'es', 16 | sourcemap 17 | } 18 | ], 19 | plugins 20 | } 21 | 22 | const browserBuild = { 23 | input: './src/websocket/browser.js', 24 | output: { 25 | file: 'dist/websocket/browser.js', 26 | format: 'umd', 27 | name: 'hyperactiv-websocket', 28 | sourcemap 29 | }, 30 | plugins 31 | } 32 | 33 | export default [ 34 | serverBuild, 35 | browserBuild 36 | ] 37 | -------------------------------------------------------------------------------- /docs/bench.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elbywan/hyperactiv/9fdf71f39446bfcbe4a44189b5005eea4cee65bf/docs/bench.png -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | text-align: center; 5 | font-family: 'Open Sans', sans-serif; 6 | } 7 | 8 | a, a:active { 9 | color: inherit; 10 | } 11 | 12 | header { 13 | font-family: 'Encode Sans Expanded', sans-serif; 14 | background: rgb(255, 0, 55); 15 | color: white; 16 | padding: 20px; 17 | } 18 | 19 | header .github-link { 20 | position: relative; 21 | display: inline-block; 22 | vertical-align: middle; 23 | height: 1.7em; 24 | width: 50px; 25 | transition: all 0.25s; 26 | } 27 | header .github-link:hover { 28 | fill: white; 29 | } 30 | 31 | div.description { 32 | border-bottom: 2px solid #DDD; 33 | padding: 50px; 34 | margin-bottom: 50px; 35 | font-weight: bold; 36 | } 37 | 38 | .highlight { 39 | color: rgb(255, 0, 55); 40 | } 41 | 42 | .button--unstyled { 43 | cursor: pointer; 44 | margin: 0; 45 | border: 0; 46 | padding: 0; 47 | white-space: normal; 48 | background: none; 49 | line-height: 1; 50 | outline: none; 51 | } -------------------------------------------------------------------------------- /docs/mwe/react.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /docs/paint/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hyperactiv - paint demo 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

18 | Hyperactiv 19 | 20 | 21 | 23 | 24 | 25 | 26 |

27 |

Paint demo

28 |
29 |
30 |

31 | Click on the square below to add a green, blue or red color pixel which will dissolve like clouds of paint.
33 | The color of each pixel is automatically computed to be equal to the weighted 34 | mean of its neighbours. 35 |

36 |

Check out the source code !

39 |

(Computations are slowed down in order to make the propagation visible)

40 |
41 |
42 | 43 | 44 | 45 | 46 | 48 |
49 |
50 | 51 | 52 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /docs/paint/paint.css: -------------------------------------------------------------------------------- 1 | div.color-buttons { 2 | margin-bottom: 20px; 3 | } 4 | div.color-buttons > button, div.color-buttons > input { 5 | display: inline-block; 6 | vertical-align: middle; 7 | cursor: pointer; 8 | border: 1px solid #DDD; 9 | color: #666; 10 | background: white; 11 | height: 3em; 12 | padding: 0; 13 | margin-right: 10px; 14 | width: 100px; 15 | border-radius: 5px; 16 | outline: none; 17 | text-transform: uppercase; 18 | transition: 0.25s all; 19 | } 20 | div.color-buttons > button:hover, div.color-buttons > input:hover { 21 | color: #222; 22 | background: #EEE; 23 | border-color: #EEE; 24 | } 25 | div.color-buttons > button.selected, div.color-buttons > input.selected { 26 | color: white; 27 | background: rgb(255, 0, 55); 28 | border-color: rgb(255, 0, 55); 29 | } 30 | 31 | div#container { 32 | width: 500px; 33 | height: 500px; 34 | margin-bottom: 50px; 35 | border-radius: 15px; 36 | box-shadow: 0px 0px 10px #CCC; 37 | overflow: hidden; 38 | position: relative; 39 | display: inline-flex; 40 | flex-direction: column; 41 | } 42 | 43 | div#container > div { 44 | flex: 1; 45 | display: inline-flex; 46 | } 47 | 48 | div#container > div > span { 49 | flex: 1; 50 | } 51 | div#container > div > span:hover { 52 | border: 1px solid black; 53 | } -------------------------------------------------------------------------------- /docs/todos/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hyperactiv - todos demo 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |

22 | Hyperactiv 23 | 24 | 25 | 27 | 28 | 29 | 30 |

31 |

Todos store demo

32 |
33 |
34 |

35 | This is a very simple (but smart) implementation of a reactive store used in 36 | a react app.
37 | The components are linked to the store, and automatically re-rendered when the data they need (only) is 38 | updated. 39 |

40 |

Check out the source 42 | code !

43 |
44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/todos/index.js: -------------------------------------------------------------------------------- 1 | /* global React, ReactDOM */ 2 | const { Fragment, memo } = React 3 | const { watch, Watch, store: hyperactivStore, setHooksDependencies } = window['react-hyperactiv'] 4 | 5 | setHooksDependencies({ wretch: window.wretch }) 6 | 7 | /* Store */ 8 | 9 | const store = hyperactivStore({ 10 | todos: [ 11 | { id: 2, label: 'Do the laundry', completed: false }, 12 | { id: 1, label: 'Buy shampoo', completed: false }, 13 | { id: 0, label: 'Create a todo list', completed: true } 14 | ], 15 | filterTodos: null, 16 | newTodoLabel: '' 17 | }) 18 | 19 | const todosActions = { 20 | idSequence: 3, 21 | add(label) { 22 | label = typeof label === 'string' ? label : 'New todo' 23 | store.todos.unshift({ id: todosActions.idSequence++, label, completed: false }) 24 | }, 25 | remove(todo) { 26 | const index = store.todos.indexOf(todo) 27 | store.todos.splice(index, 1) 28 | }, 29 | completeAll() { 30 | const completion = store.todos.some(({ completed }) => !completed) 31 | store.todos.forEach(todo => todo.completed = completion) 32 | }, 33 | clearCompleted() { 34 | store.todos = store.todos.filter(todo => !todo.completed) 35 | } 36 | } 37 | 38 | /* Components */ 39 | 40 | const App = memo(watch(function App() { 41 | const { total, completed, active } = { 42 | total: store.todos.length, 43 | completed: store.todos.filter(_ => _.completed).length, 44 | active: store.todos.filter(_ => !_.completed).length 45 | } 46 | 47 | function renderFilter(label, filter = label) { 48 | return ( 49 | store.filterTodos = filter } 51 | className="highlight" 52 | > 53 | { label } 54 | 55 | ) 56 | } 57 | 58 | return ( 59 |
60 |
61 | There is a { renderFilter('total', false) } of { total } todo(s). 62 | ({ completed } { renderFilter('completed') }, { active } { renderFilter('active') }) 63 |
64 | 65 |
66 | ) 67 | })) 68 | 69 | const NewTodoForm = memo(watch(function NewTodoForm() { 70 | function submitNewTodo(event) { 71 | event.preventDefault() 72 | if(store.newTodoLabel.trim()) { 73 | todosActions.add(store.newTodoLabel) 74 | store.newTodoLabel = '' 75 | } 76 | } 77 | 78 | return ( 79 |
80 | store.newTodoLabel = event.target.value }/> 85 |
86 | ) 87 | })) 88 | 89 | 90 | const Todos = memo(watch(function Todos() { 91 | const todosList = store.todos.reduce((acc, todo) => { 92 | if( 93 | !store.filterTodos || 94 | store.filterTodos === 'completed' && todo.completed || 95 | store.filterTodos === 'active' && !todo.completed 96 | ) { 97 | acc.push() 98 | } 99 | return acc 100 | }, []) 101 | 102 | return ( 103 | 104 |
105 | 108 | 111 | 114 |
115 | 116 |
    117 | { todosList.length > 0 ? 118 | todosList : 119 |
    120 | There are no 121 | {store.filterTodos || ''} 122 | todos! 123 |
    124 | } 125 |
126 |
127 | ) 128 | })) 129 | 130 | const Todo = memo(function Todo({ todo }) { 131 | return ( 132 | 133 |
134 | todo.label = event.target.value }/> 138 | 142 | todo.completed = !todo.completed } 146 | /> 147 |
148 | }/> 149 | ) 150 | }) 151 | 152 | /* Mounting */ 153 | 154 | ReactDOM.render( 155 | , 156 | document.getElementById('todos-root') 157 | ) -------------------------------------------------------------------------------- /docs/todos/todos.css: -------------------------------------------------------------------------------- 1 | #todos-root { 2 | width: 420px; 3 | padding-bottom: 20px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | box-shadow: 2px 2px 10px #AAA; 7 | overflow: hidden; 8 | font-size: 0.8rem; 9 | } 10 | 11 | #todos-root input[type="text"] { 12 | border: 0; 13 | border-bottom: 1px solid #DDD; 14 | width: 275px; 15 | padding: 5px 0px; 16 | font-size: 1em; 17 | color: #666; 18 | outline: none; 19 | transition: all 0.4s; 20 | } 21 | #todos-root input[type="text"]::placeholder { 22 | color: #BBB; 23 | } 24 | #todos-root input[type="text"]:focus { 25 | border-color: rgb(255, 0, 55); 26 | color: black; 27 | } 28 | #todos-root input[type="text"].done { 29 | text-decoration: line-through; 30 | color: #AAA; 31 | } 32 | 33 | #todos-root ul { 34 | padding: 0px 10px; 35 | padding-top: 10px; 36 | margin: 0px; 37 | } 38 | 39 | #todos-root ul div.placeholder-text { 40 | margin-top: 10px; 41 | color: #666; 42 | } 43 | 44 | div.counters { 45 | padding: 15px; 46 | background: #EEE; 47 | color: #888; 48 | } 49 | 50 | .buttons__bar { 51 | display: flex; 52 | border-top: 1px solid #DDD; 53 | border-bottom: 1px solid #DDD; 54 | } 55 | .buttons__bar > button { 56 | flex: 1; 57 | padding: 10px; 58 | color: #888; 59 | background: #EEE; 60 | transition: all 0.4s; 61 | } 62 | .buttons__bar > button:last-child { 63 | margin-right: 0px; 64 | } 65 | .buttons__bar > button:hover { 66 | background: #CCC; 67 | color: #FFF; 68 | } 69 | 70 | .todo__form { 71 | width: 95%; 72 | margin-left: auto; 73 | margin-right: auto; 74 | margin-top: 10px; 75 | padding: 10px 0px 0px 0px; 76 | } 77 | .todo__form > input[type="text"] { 78 | width: calc(100% - 40px) !important; 79 | padding-left: 20px !important; 80 | padding-right: 20px !important; 81 | padding-bottom: 10px !important; 82 | } 83 | 84 | .todo__bar { 85 | margin: 20px 0px; 86 | display: flex; 87 | align-items: center; 88 | justify-content: space-around; 89 | } 90 | 91 | .todo__bar > * { 92 | display: inline-block; 93 | vertical-align: middle; 94 | } 95 | 96 | .todo__bar button { 97 | padding: 10px; 98 | color: #888; 99 | margin-left: 5px; 100 | margin-right: 5px; 101 | transition: all 0.4s; 102 | } 103 | .todo__bar button:hover { 104 | color: rgb(255, 0, 55); 105 | } -------------------------------------------------------------------------------- /http/index.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("wretch"),require("normaliz")):"function"==typeof define&&define.amd?define(["exports","wretch","normaliz"],t):t((e=e||self)["hyperactiv-http"]={},e.wretch,e.normaliz)}(this,function(e,t,r){"use strict";t=t&&t.hasOwnProperty("default")?t.default:t;const n="__requests__",o=(e,t)=>`${e}@${t}`,c=e=>e,u={read(e,t){const r={};return Object.entries(e).forEach(([e,n])=>{r[e]={},n.forEach(n=>{r[e][n]=t[e]&&t[e][n]||null})}),r},write(e,t){Object.entries(e).forEach(([e,r])=>{t[e]||(t[e]={}),Object.entries(r).forEach(([r,n])=>{t[e][r]&&"object"==typeof t[e][r]&&"object"==typeof n?Object.entries(n).forEach(([n,o])=>{t[e][r][n]=o}):t[e][r]=n})})}};function i(e,{store:i,normalize:s,client:l=t(),beforeRequest:f=c,afterRequest:a=c,rootKey:y=n,serialize:h=o,bodyType:d="json",policy:p="cache-first"}){const b=f(l.url(e)),j=h("get",b._url);i[y]||(i[y]={});const q=i[y][j],z="network-only"!==p&&q&&u.read(q,i)||null;function m(){return b.get()[d](e=>a(e)).then(e=>{const t=r.normaliz(e,s);return i[y][j]=Object.entries(t).reduce((e,[t,r])=>(e[t]=Object.keys(r),e),{}),u.write(t,i),u.read(i[y][j],i)})}const w="cache-first"===p&&z?null:m();return{data:z,refetch:m,future:w}}function s(e,t,r){return e?null!==r?e[t]&&e[t][r]:e[t]&&Object.values(e[t]):e}e.normalized=i,e.request=function(e,{store:r,client:u=t(),beforeRequest:i=c,afterRequest:s=c,rootKey:l=n,serialize:f=o,bodyType:a="json",policy:y="cache-first"}){const h=i(u.url(e)),d=f("get",h._url);r[l]||(r[l]={});const p=r[l][d],b="network-only"!==y&&p||null;function j(){return h.get()[a](e=>s(e)).then(e=>(r[l][d]=e,e))}const q="cache-first"===y&&b?null:j();return{data:b,refetch:j,future:q}},e.resource=function(e,t,{id:r=null,store:n,normalize:o,client:c,beforeRequest:u,afterRequest:l,serialize:f,rootKey:a,bodyType:y,policy:h="cache-first"}){const d=r&&n[e]&&n[e][r],{data:p,future:b,refetch:j}=i(t,{store:n,normalize:{schema:[],...o,entity:e},client:c,beforeRequest:u,afterRequest:l,serialize:f,rootKey:a,bodyType:y,policy:h});return{data:"network-only"!==h&&d||s(p,e,r),future:b&&b.then(t=>s(t,e,r))||null,refetch:()=>j().then(t=>s(t,e,r))}},Object.defineProperty(e,"__esModule",{value:!0})}); 2 | //# sourceMappingURL=index.js.map 3 | -------------------------------------------------------------------------------- /http/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sources":["../src/http/tools.js","../src/http/normalized.js","../src/http/resource.js","../src/http/request.js"],"sourcesContent":["export const defaultRootKey = '__requests__'\nexport const defaultSerialize = (method, url) => `${method}@${url}`\nexport const identity = _ => _\n\nexport const normalizedOperations = {\n read(mappings, store) {\n const storeFragment = {}\n Object.entries(mappings).forEach(([ entity, ids ]) => {\n storeFragment[entity] = {}\n ids.forEach(key => {\n storeFragment[entity][key] = store[entity] && store[entity][key] || null\n })\n })\n return storeFragment\n },\n write(normalizedData, store) {\n Object.entries(normalizedData).forEach(([ entity, entityData ]) => {\n if(!store[entity]) {\n store[entity] = {}\n }\n\n Object.entries(entityData).forEach(([ key, value ]) => {\n if(store[entity][key]) {\n if(typeof store[entity][key] === 'object' && typeof value === 'object') {\n Object.entries(value).forEach(([k, v]) => {\n store[entity][key][k] = v\n })\n } else {\n store[entity][key] = value\n }\n } else {\n store[entity][key] = value\n }\n })\n })\n }\n}\n","import wretch from 'wretch'\nimport { normaliz } from 'normaliz'\n\nimport { identity, defaultSerialize, defaultRootKey, normalizedOperations } from './tools'\n\nexport function normalized(url, {\n store,\n normalize,\n client = wretch(),\n beforeRequest = identity,\n afterRequest = identity,\n rootKey = defaultRootKey,\n serialize = defaultSerialize,\n bodyType = 'json',\n policy = 'cache-first'\n}) {\n const configuredClient = beforeRequest(client.url(url))\n const storeKey = serialize('get', configuredClient._url)\n if(!store[rootKey]) {\n store[rootKey] = {}\n }\n const storedMappings = store[rootKey][storeKey]\n const cacheLookup = policy !== 'network-only'\n const data =\n cacheLookup &&\n storedMappings &&\n normalizedOperations.read(storedMappings, store) ||\n null\n\n function refetch() {\n return configuredClient\n .get()\n // eslint-disable-next-line no-unexpected-multiline\n [bodyType](body => afterRequest(body))\n .then(result => {\n const normalizedData = normaliz(result, normalize)\n store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => {\n mappings[entity] = Object.keys(dataById)\n return mappings\n }, {})\n normalizedOperations.write(normalizedData, store)\n const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store)\n return storeSlice\n })\n }\n\n const future = policy !== 'cache-first' || !data ? refetch() : null\n\n return {\n data,\n refetch,\n future\n }\n}\n","import { normalized } from './normalized'\n\nfunction formatData(data, entity, id) {\n return (\n data ?\n id !== null ?\n data[entity] && data[entity][id] :\n data[entity] && Object.values(data[entity]) :\n data\n )\n}\n\nexport function resource(entity, url, {\n id = null,\n store,\n normalize,\n client,\n beforeRequest,\n afterRequest,\n serialize,\n rootKey,\n bodyType,\n policy = 'cache-first'\n}) {\n const storedEntity = id && store[entity] && store[entity][id]\n\n const {\n data,\n future,\n refetch: normalizedRefetch\n } = normalized(url, {\n store,\n normalize: {\n schema: [],\n ...normalize,\n entity\n },\n client,\n beforeRequest,\n afterRequest,\n serialize,\n rootKey,\n bodyType,\n policy\n })\n\n const refetch = () => normalizedRefetch().then(data =>\n formatData(data, entity, id)\n )\n\n return {\n data: policy !== 'network-only' && storedEntity || formatData(data, entity, id),\n future: future && future.then(data => formatData(data, entity, id)) || null,\n refetch\n }\n}\n","import wretch from 'wretch'\n\nimport { identity, defaultSerialize, defaultRootKey } from './tools'\n\nexport function request(url, {\n store,\n client = wretch(),\n beforeRequest = identity,\n afterRequest = identity,\n rootKey = defaultRootKey,\n serialize = defaultSerialize,\n bodyType = 'json',\n policy = 'cache-first'\n}) {\n const configuredClient = beforeRequest(client.url(url))\n const storeKey = serialize('get', configuredClient._url)\n if(!store[rootKey]) {\n store[rootKey] = {}\n }\n const storedData = store[rootKey][storeKey]\n const cacheLookup = policy !== 'network-only'\n const data = cacheLookup && storedData || null\n\n function refetch() {\n return configuredClient\n .get()\n // eslint-disable-next-line no-unexpected-multiline\n [bodyType](body => afterRequest(body))\n .then(result => {\n store[rootKey][storeKey] = result\n return result\n })\n }\n\n const future = policy !== 'cache-first' || !data ? refetch() : null\n\n return {\n data,\n refetch,\n future\n }\n}\n"],"names":["defaultRootKey","defaultSerialize","method","url","identity","_","normalizedOperations","[object Object]","mappings","store","storeFragment","Object","entries","forEach","entity","ids","key","normalizedData","entityData","value","k","v","normalized","normalize","client","wretch","beforeRequest","afterRequest","rootKey","serialize","bodyType","policy","configuredClient","storeKey","_url","storedMappings","data","read","refetch","get","body","then","result","normaliz","reduce","dataById","keys","write","future","formatData","id","values","storedData","storedEntity","normalizedRefetch","schema"],"mappings":"8UAAO,MAAMA,EAAiB,eACjBC,EAAmB,CAACC,EAAQC,OAAWD,KAAUC,IACjDC,EAAWC,GAAKA,EAEhBC,EAAuB,CAChCC,KAAKC,EAAUC,GACX,MAAMC,EAAgB,GAOtB,OANAC,OAAOC,QAAQJ,GAAUK,QAAQ,EAAGC,EAAQC,MACxCL,EAAcI,GAAU,GACxBC,EAAIF,QAAQG,IACRN,EAAcI,GAAQE,GAAOP,EAAMK,IAAWL,EAAMK,GAAQE,IAAQ,SAGrEN,GAEXH,MAAMU,EAAgBR,GAClBE,OAAOC,QAAQK,GAAgBJ,QAAQ,EAAGC,EAAQI,MAC1CT,EAAMK,KACNL,EAAMK,GAAU,IAGpBH,OAAOC,QAAQM,GAAYL,QAAQ,EAAGG,EAAKG,MACpCV,EAAMK,GAAQE,IACoB,iBAAvBP,EAAMK,GAAQE,IAAsC,iBAAVG,EAChDR,OAAOC,QAAQO,GAAON,QAAQ,EAAEO,EAAGC,MAC/BZ,EAAMK,GAAQE,GAAKI,GAAKC,IAMhCZ,EAAMK,GAAQE,GAAOG,QC1BlC,SAASG,EAAWnB,GAAKM,MAC5BA,EAAKc,UACLA,EAASC,OACTA,EAASC,IAAQC,cACjBA,EAAgBtB,EAAQuB,aACxBA,EAAevB,EAAQwB,QACvBA,EAAU5B,EAAc6B,UACxBA,EAAY5B,EAAgB6B,SAC5BA,EAAW,OAAMC,OACjBA,EAAS,gBAET,MAAMC,EAAmBN,EAAcF,EAAOrB,IAAIA,IAC5C8B,EAAWJ,EAAU,MAAOG,EAAiBE,MAC/CzB,EAAMmB,KACNnB,EAAMmB,GAAW,IAErB,MAAMO,EAAiB1B,EAAMmB,GAASK,GAEhCG,EADyB,iBAAXL,GAGhBI,GACA7B,EAAqB+B,KAAKF,EAAgB1B,IAC1C,KAEJ,SAAS6B,IACL,OAAON,EACFO,MAEAT,GAAUU,GAAQb,EAAaa,IAC/BC,KAAKC,IACF,MAAMzB,EAAiB0B,WAASD,EAAQnB,GAOxC,OANAd,EAAMmB,GAASK,GAAYtB,OAAOC,QAAQK,GAAgB2B,OAAO,CAACpC,GAAYM,EAAQ+B,MAClFrC,EAASM,GAAUH,OAAOmC,KAAKD,GACxBrC,GACR,IACHF,EAAqByC,MAAM9B,EAAgBR,GACxBH,EAAqB+B,KAAK5B,EAAMmB,GAASK,GAAWxB,KAKnF,MAAMuC,EAAoB,gBAAXjB,GAA6BK,EAAmB,KAAZE,IAEnD,MAAO,CACHF,KAAAA,EACAE,QAAAA,EACAU,OAAAA,GCjDR,SAASC,EAAWb,EAAMtB,EAAQoC,GAC9B,OACId,EACW,OAAPc,EACId,EAAKtB,IAAWsB,EAAKtB,GAAQoC,GAC7Bd,EAAKtB,IAAWH,OAAOwC,OAAOf,EAAKtB,IACvCsB,2BCJL,SAAiBjC,GAAKM,MACzBA,EAAKe,OACLA,EAASC,IAAQC,cACjBA,EAAgBtB,EAAQuB,aACxBA,EAAevB,EAAQwB,QACvBA,EAAU5B,EAAc6B,UACxBA,EAAY5B,EAAgB6B,SAC5BA,EAAW,OAAMC,OACjBA,EAAS,gBAET,MAAMC,EAAmBN,EAAcF,EAAOrB,IAAIA,IAC5C8B,EAAWJ,EAAU,MAAOG,EAAiBE,MAC/CzB,EAAMmB,KACNnB,EAAMmB,GAAW,IAErB,MAAMwB,EAAa3C,EAAMmB,GAASK,GAE5BG,EADyB,iBAAXL,GACQqB,GAAc,KAE1C,SAASd,IACL,OAAON,EACFO,MAEAT,GAAUU,GAAQb,EAAaa,IAC/BC,KAAKC,IACFjC,EAAMmB,GAASK,GAAYS,EACpBA,IAInB,MAAMM,EAAoB,gBAAXjB,GAA6BK,EAAmB,KAAZE,IAEnD,MAAO,CACHF,KAAAA,EACAE,QAAAA,EACAU,OAAAA,eD3BD,SAAkBlC,EAAQX,GAAK+C,GAClCA,EAAK,KAAIzC,MACTA,EAAKc,UACLA,EAASC,OACTA,EAAME,cACNA,EAAaC,aACbA,EAAYE,UACZA,EAASD,QACTA,EAAOE,SACPA,EAAQC,OACRA,EAAS,gBAET,MAAMsB,EAAeH,GAAMzC,EAAMK,IAAWL,EAAMK,GAAQoC,IAEpDd,KACFA,EAAIY,OACJA,EACAV,QAASgB,GACThC,EAAWnB,EAAK,CAChBM,MAAAA,EACAc,UAAW,CACPgC,OAAQ,MACLhC,EACHT,OAAAA,GAEJU,OAAAA,EACAE,cAAAA,EACAC,aAAAA,EACAE,UAAAA,EACAD,QAAAA,EACAE,SAAAA,EACAC,OAAAA,IAOJ,MAAO,CACHK,KAAiB,iBAAXL,GAA6BsB,GAAgBJ,EAAWb,EAAMtB,EAAQoC,GAC5EF,OAAQA,GAAUA,EAAOP,KAAKL,GAAQa,EAAWb,EAAMtB,EAAQoC,KAAQ,KACvEZ,QAPY,IAAMgB,IAAoBb,KAAKL,GAC3Ca,EAAWb,EAAMtB,EAAQoC"} -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 31 | 36 | 37 | 39 | 43 | 44 | 47 | 51 | 55 | 56 | 63 | 73 | 74 | 99 | 101 | 102 | 104 | image/svg+xml 105 | 107 | 108 | 109 | 110 | 111 | 117 | 126 | 132 | 141 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperactiv", 3 | "version": "0.11.3", 4 | "description": "Super small observable & reactive objects library.", 5 | "main": "./dist/index.js", 6 | "module": "./src/index.js", 7 | "types": "./types/index.d.ts", 8 | "typesVersions": { 9 | "*": { 10 | "src": [ 11 | "./types/index.d.ts" 12 | ], 13 | "dist": [ 14 | "./types/index.d.ts" 15 | ], 16 | "react": [ 17 | "./types/react/index.d.ts" 18 | ], 19 | "src/react": [ 20 | "./types/react/index.d.ts" 21 | ], 22 | "classes": [ 23 | "./types/classes/index.d.ts" 24 | ], 25 | "src/classes": [ 26 | "./types/classes/index.d.ts" 27 | ], 28 | "handlers": [ 29 | "./types/handlers/index.d.ts" 30 | ], 31 | "src/handlers": [ 32 | "./types/handlers/index.d.ts" 33 | ], 34 | "http": [ 35 | "./types/http/index.d.ts" 36 | ], 37 | "src/http": [ 38 | "./types/http/index.d.ts" 39 | ], 40 | "websocket/browser": [ 41 | "./types/websocket/browser.d.ts" 42 | ], 43 | "src/websocket/browser": [ 44 | "./types/websocket/browser.d.ts" 45 | ], 46 | "websocket/server": [ 47 | "./types/websocket/server.d.ts" 48 | ], 49 | "src/websocket/server": [ 50 | "./types/websocket/server.d.ts" 51 | ] 52 | } 53 | }, 54 | "exports": { 55 | ".": { 56 | "node": { 57 | "import": "./dist/index.mjs", 58 | "require": "./dist/index.js" 59 | }, 60 | "default": "./src/index.js" 61 | }, 62 | "./react": { 63 | "node": { 64 | "import": "./dist/react/index.mjs", 65 | "require": "./dist/react/index.js" 66 | }, 67 | "default": "./src/react/index.js" 68 | }, 69 | "./classes": { 70 | "node": { 71 | "import": "./dist/classes/index.mjs", 72 | "require": "./dist/classes/index.js" 73 | }, 74 | "default": "./src/classes/index.js" 75 | }, 76 | "./handlers": { 77 | "node": { 78 | "import": "./dist/handlers/index.mjs", 79 | "require": "./dist/handlers/index.js" 80 | }, 81 | "default": "./src/handlers/index.js" 82 | }, 83 | "./http": { 84 | "node": { 85 | "import": "./dist/http/index.mjs", 86 | "require": "./dist/http/index.js" 87 | }, 88 | "default": "./src/http/index.js" 89 | }, 90 | "./websocket/server": { 91 | "import": "./dist/websocket/server.mjs", 92 | "require": "./dist/websocket/server.js" 93 | }, 94 | "./websocket/browser": { 95 | "default": "./dist/websocket/browser.js" 96 | }, 97 | "./package.json": "./package.json" 98 | }, 99 | "repository": "https://github.com/elbywan/hyperactiv", 100 | "bugs": { 101 | "url": "https://github.com/elbywan/hyperactiv/issues" 102 | }, 103 | "files": [ 104 | "src", 105 | "types", 106 | "dist" 107 | ], 108 | "scripts": { 109 | "start": "npm run lint && npm run build && npm run test", 110 | "lint": "eslint ./src ./test", 111 | "lint:fix": "eslint --fix ./src ./test", 112 | "build": "npm run build:types && npm run build:core && npm run build:handlers && npm run build:react && npm run build:classes && npm run build:websocket && npm run build:http", 113 | "build:types": "tsc", 114 | "build:core": "rollup -c config/rollup.config.js", 115 | "build:handlers": "rollup -c config/rollup.handlers.config.js", 116 | "build:react": "rollup -c config/rollup.react.config.js", 117 | "build:http": "rollup -c config/rollup.http.config.js", 118 | "build:classes": "rollup -c config/rollup.classes.config.js", 119 | "build:websocket": "rollup -c config/rollup.websocket.config.js", 120 | "test": "jest", 121 | "clean": "rimraf {dist,types}", 122 | "prepublishOnly": "npm start" 123 | }, 124 | "keywords": [ 125 | "computed properties", 126 | "computed", 127 | "reactive", 128 | "observable", 129 | "observe", 130 | "react", 131 | "store", 132 | "normalize" 133 | ], 134 | "author": "Julien Elbaz", 135 | "license": "MIT", 136 | "jest": { 137 | "collectCoverage": true, 138 | "collectCoverageFrom": [ 139 | "src/**/*.js" 140 | ], 141 | "coveragePathIgnorePatterns": [ 142 | "/node_modules/", 143 | "/test/", 144 | "/src/websocket/browser.js" 145 | ] 146 | }, 147 | "devDependencies": { 148 | "@babel/core": "^7.17.10", 149 | "@babel/eslint-parser": "^7.17.0", 150 | "@babel/preset-env": "^7.17.10", 151 | "@babel/preset-react": "^7.16.7", 152 | "@testing-library/jest-dom": "^5.16.4", 153 | "@testing-library/react": "^13.2.0", 154 | "@types/jest": "^27.5.0", 155 | "babel-jest": "^28.1.0", 156 | "eslint": "^8.15.0", 157 | "eslint-plugin-jest": "^26.1.5", 158 | "eslint-plugin-react": "^7.29.4", 159 | "jest": "^28.1.0", 160 | "jest-environment-jsdom": "^28.1.0", 161 | "node-fetch": "^2", 162 | "normaliz": "^0.2.0", 163 | "react": "^18.1.0", 164 | "react-dom": "^18.1.0", 165 | "react-test-renderer": "^18.1.0", 166 | "rimraf": "^3.0.2", 167 | "rollup": "^2.72.0", 168 | "rollup-plugin-terser": "^7.0.2", 169 | "typescript": "^4.6.4", 170 | "wretch": "^1.7.9", 171 | "ws": "^7" 172 | }, 173 | "peerDependenciesMeta": { 174 | "react": { 175 | "optional": true 176 | }, 177 | "react-dom": { 178 | "optional": true 179 | }, 180 | "normaliz": { 181 | "optional": true 182 | }, 183 | "wretch": { 184 | "optional": true 185 | } 186 | } 187 | } -------------------------------------------------------------------------------- /src/batcher.js: -------------------------------------------------------------------------------- 1 | let queue = null 2 | export const __batched = Symbol() 3 | 4 | /** 5 | * Will perform batched computations instantly. 6 | */ 7 | export function process() { 8 | if(!queue) 9 | return 10 | for(const task of queue) { 11 | task() 12 | task[__batched] = false 13 | } 14 | queue = null 15 | } 16 | 17 | export function enqueue(task, batch) { 18 | if(task[__batched]) 19 | return 20 | if(queue === null) { 21 | queue = [] 22 | if(batch === true) { 23 | queueMicrotask(process) 24 | } else { 25 | setTimeout(process, batch) 26 | } 27 | } 28 | queue.push(task) 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/classes/README.md: -------------------------------------------------------------------------------- 1 | # hyperactiv/classes 2 | 3 | ### An Observable class 4 | 5 | Implementation of an Observable class that can observe/compute its own properties. 6 | 7 | ### Import 8 | 9 | ```js 10 | import classes from 'hyperactiv/classes' 11 | ``` 12 | 13 | Or alternatively if you prefer script tags : 14 | 15 | ```html 16 | 17 | 18 | ``` 19 | 20 | ```js 21 | const { Observable } = window['hyperactiv-classes'] 22 | ``` 23 | 24 | ### Usage 25 | 26 | ```js 27 | const observed = new Observable({ 28 | a: 1, 29 | b: 1, 30 | sum: 0 31 | }) 32 | 33 | observed.computed(function() { 34 | this.sum = this.a + this.b 35 | }) 36 | 37 | console.log(observed.sum) 38 | // log> 2 39 | 40 | // Same syntax as handlers. See: hyperactiv/handlers 41 | observed.onChange(function(keys, value, old, obj) { 42 | console.log('Assigning', value, 'to', keys) 43 | }) 44 | 45 | observed.a = 2 46 | // log> Assigning 2 to a 47 | // log> Assigning 3 to sum 48 | 49 | console.log(observed.sum) 50 | // log> 3 51 | 52 | observed.dispose() //cleans up computed functions 53 | 54 | observed.a = 1 55 | // log> Assigning 1 to a 56 | 57 | console.log(observed.sum) 58 | // log> 3 59 | ``` 60 | 61 | ### API 62 | 63 | #### new Observable(data = {}, options = {}) 64 | 65 | Creates a new Observable object. 66 | 67 | - data - *optional*: adds some data to the observable. 68 | - options - *optional*: hyperactiv `observe()` options. 69 | 70 | #### computed(fun, options) 71 | 72 | Registers a computed function. 73 | 74 | - fun - *required*: a computed function bound to the instance. 75 | - options - *optional*: hyperactiv `computed()` options. 76 | 77 | #### onChange(fun) 78 | 79 | Registers a handler. See: hyperactiv/handlers 80 | 81 | - fun -*required*: a handler function. 82 | 83 | #### dispose() 84 | 85 | Dispose and remove every computed function that has been registered. 86 | -------------------------------------------------------------------------------- /src/classes/index.js: -------------------------------------------------------------------------------- 1 | import hyperactiv from '../index.js' 2 | const { observe, computed, dispose } = hyperactiv 3 | 4 | export class Observable { 5 | constructor(data = {}, options) { 6 | Object.assign(this, data) 7 | Object.defineProperty(this, '__computed', { value: [], enumerable: false }) 8 | return observe(this, Object.assign({ bubble: true }, options)) 9 | } 10 | 11 | computed(fn, opt) { 12 | this.__computed.push(computed(fn.bind(this), opt)) 13 | } 14 | 15 | onChange(fn) { 16 | this.__handler = fn 17 | } 18 | 19 | dispose() { 20 | while(this.__computed.length) { 21 | dispose(this.__computed.pop()) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/computed.js: -------------------------------------------------------------------------------- 1 | import { data } from './data.js' 2 | const { computedStack, trackerSymbol } = data 3 | 4 | /** 5 | * @typedef {Object} ComputedArguments - Computed Arguments. 6 | * @property {(fun: () => void) => void} computeAsync - 7 | * Will monitor the dependencies of the function passed as an argument. Useful when dealing with asynchronous computations. 8 | */ 9 | 10 | /** 11 | * @typedef {Object} Options - Computed Options. 12 | * @property {boolean} [autoRun] - 13 | * If false, will not run the function argument when calling computed(function). 14 | * The computed function must be called **at least once** to calculate its dependencies. 15 | * @property {() => void} [callback] - 16 | * Specify a callback that will be re-runned each time a dependency changes instead of the computed function. 17 | */ 18 | 19 | /** 20 | * Wraps a function and captures observed properties which are accessed during the function execution. 21 | * When those properties are mutated, the function is called to reflect the changes. 22 | * 23 | * @param {(args: ComputedArguments) => void} wrappedFunction 24 | * @param {Options} options 25 | */ 26 | export function computed(wrappedFunction, { autoRun = true, callback, bind, disableTracking = false } = {}) { 27 | function observeComputation(fun, argsList = []) { 28 | const target = callback || wrapper 29 | // Track object and object properties accessed during this function call 30 | if(!disableTracking) { 31 | target[trackerSymbol] = new WeakMap() 32 | } 33 | // Store into the stack a reference to the computed function 34 | computedStack.unshift(target) 35 | // Inject the computeAsync argument which is used to manually declare when the computation takes part 36 | if(argsList.length > 0) { 37 | argsList = [...argsList, computeAsyncArg] 38 | } else { 39 | argsList = [computeAsyncArg] 40 | } 41 | // Run the computed function - or the async function 42 | const result = 43 | fun ? fun() : 44 | bind ? wrappedFunction.apply(bind, argsList) : 45 | wrappedFunction(...argsList) 46 | // Remove the reference 47 | computedStack.shift() 48 | // Return the result 49 | return result 50 | } 51 | const computeAsyncArg = { computeAsync: observeComputation } 52 | const wrapper = (...argsList) => observeComputation(null, argsList) 53 | 54 | // If autoRun, then call the function at once 55 | if(autoRun) { 56 | wrapper() 57 | } 58 | 59 | return wrapper 60 | } 61 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | export const data = { 2 | computedStack: [], 3 | trackerSymbol: Symbol('tracker') 4 | } 5 | -------------------------------------------------------------------------------- /src/dispose.js: -------------------------------------------------------------------------------- 1 | import { data } from './data.js' 2 | 3 | /** 4 | * Will remove the computed function from the reactive Maps (the next time an bound observer property is called) allowing garbage collection. 5 | * 6 | * @param {Function} computedFunction 7 | */ 8 | export function dispose(computedFunction) { 9 | computedFunction[data.trackerSymbol] = null 10 | return computedFunction.__disposed = true 11 | } 12 | -------------------------------------------------------------------------------- /src/handlers/README.md: -------------------------------------------------------------------------------- 1 | # hyperactiv/handlers 2 | 3 | ### Utility callbacks triggered when a property is mutated 4 | 5 | Helper handlers can be used to perform various tasks whenever an observed object is mutated. 6 | 7 | ### Import 8 | 9 | ```js 10 | import handlers from 'hyperactiv/handlers' 11 | ``` 12 | 13 | Or alternatively if you prefer script tags : 14 | 15 | ```html 16 | 17 | ``` 18 | 19 | ```js 20 | const handlers = window['hyperactiv-handlers'] 21 | ``` 22 | 23 | ### Usage 24 | 25 | You can "wire tap" any observed object by assigning a callback to the `__handler` property. 26 | 27 | When the `bubble` option is set along with `deep`, the `__handler` will receive mutations from nested objects. 28 | 29 | ```javascript 30 | const observer = observe(object, { 31 | bubble: true, 32 | deep: true 33 | }) 34 | 35 | observer.__handler = (keys, value, oldValue, observedObject) => { 36 | /* stuff */ 37 | } 38 | ``` 39 | 40 | ### Available handlers 41 | 42 | #### write 43 | 44 | A handler used to transpose writes onto another object. 45 | 46 | ```javascript 47 | import hyperactiv from 'hyperactiv' 48 | import handlers from 'hyperactiv/handlers' 49 | 50 | const { observe } = hyperactiv 51 | const { write } = handlers 52 | 53 | let copy = {} 54 | let obj = observe({ 55 | __handler: write(copy) 56 | }) 57 | 58 | obj.a = 10 59 | copy.a === 10 60 | ``` 61 | 62 | #### debug 63 | 64 | This handler logs mutations. 65 | 66 | ```javascript 67 | import hyperactiv from 'hyperactiv' 68 | import handlers from 'hyperactiv/handlers' 69 | 70 | const { observe } = hyperactiv 71 | const { debug } = handlers 72 | 73 | let obj = observe({ 74 | __handler: debug(console) 75 | }) 76 | 77 | obj.a = 10 78 | 79 | // a = 10 80 | ``` 81 | 82 | #### all 83 | 84 | Run multiple handlers sequentially. 85 | 86 | ```javascript 87 | import hyperactiv from 'hyperactiv' 88 | import handlers from 'hyperactiv/handlers' 89 | 90 | const { observe } = hyperactiv 91 | const { all, write, debug } = handlers 92 | 93 | let copy = {}, copy2 = {}, obj = observe({ 94 | __handler: all([ 95 | debug(), 96 | write(copy), 97 | write(copy2) 98 | ]) 99 | }) 100 | ``` 101 | 102 | ### Example 103 | 104 | #### Catch the chain of mutated properties and perform an action 105 | 106 | ```js 107 | const object = { 108 | a: { 109 | b: [ 110 | { c: 1 } 111 | ] 112 | } 113 | } 114 | 115 | const handler = function(keys, value) { 116 | console.log('The handler is triggered after each mutation') 117 | console.log('The mutated keys are :') 118 | console.log(keys) 119 | console.log('The new value is :') 120 | console.log(value) 121 | } 122 | 123 | // The bubble and deep flags ensure that the handler will be triggered 124 | // when the mutation happens in a nested array/object 125 | const observer = observe(object, { bubble: true, deep: true }) 126 | observer.__handler = handler 127 | object.a.b[0].c = 'value' 128 | 129 | // The handler is triggered after each mutation 130 | // The mutated keys are : 131 | // [ 'a', 'b', '0', 'c'] 132 | // The new value is : 133 | // 'value' 134 | ``` 135 | -------------------------------------------------------------------------------- /src/handlers/all.js: -------------------------------------------------------------------------------- 1 | export const allHandler = function(handlers) { 2 | return Array.isArray(handlers) ? 3 | (keys, value, proxy) => handlers.forEach(fn => fn(keys, value, proxy)) : 4 | handlers 5 | } -------------------------------------------------------------------------------- /src/handlers/debug.js: -------------------------------------------------------------------------------- 1 | export const debugHandler = function(logger) { 2 | logger = logger || console 3 | return function(props, value) { 4 | const keys = props.map(prop => Number.isInteger(Number.parseInt(prop)) ? `[${prop}]` : `.${prop}`).join('').substr(1) 5 | logger.log(`${keys} = ${JSON.stringify(value, null, '\t')}`) 6 | } 7 | } -------------------------------------------------------------------------------- /src/handlers/index.js: -------------------------------------------------------------------------------- 1 | import { allHandler } from './all.js' 2 | import { debugHandler } from './debug.js' 3 | import { writeHandler } from './write.js' 4 | 5 | export default { 6 | write: writeHandler, 7 | debug: debugHandler, 8 | all: allHandler 9 | } -------------------------------------------------------------------------------- /src/handlers/write.js: -------------------------------------------------------------------------------- 1 | const getWriteContext = function(prop) { 2 | return Number.isInteger(Number.parseInt(prop, 10)) ? [] : {} 3 | } 4 | export const writeHandler = function(target) { 5 | if(!target) 6 | throw new Error('writeHandler needs a proper target !') 7 | return function(props, value) { 8 | value = typeof value === 'object' ? 9 | JSON.parse(JSON.stringify(value)) : 10 | value 11 | for(let i = 0; i < props.length - 1; i++) { 12 | var prop = props[i] 13 | if(typeof target[prop] === 'undefined') 14 | target[prop] = getWriteContext(props[i + 1]) 15 | target = target[prop] 16 | } 17 | target[props[props.length - 1]] = value 18 | } 19 | } -------------------------------------------------------------------------------- /src/http/README.md: -------------------------------------------------------------------------------- 1 | # hyperactiv/http 2 | 3 | ### A reactive http cache 4 | 5 | The http addon provides functions that can be used to query data, cache it to prevent excessive traffic and normalize it into entities. 6 | 7 | ### Use cases 8 | 9 | - Store data in a global (normalized) store 10 | - Prevent excessive data fetching, save bandwidth and time 11 | 12 | ### Import 13 | 14 | ```js 15 | import { request, normalized, resource } from 'hyperactiv/http' 16 | ``` 17 | 18 | Or alternatively if you prefer script tags : 19 | 20 | ```html 21 | 22 | ``` 23 | 24 | ```js 25 | const { request, normalized, resource } = window['hyperactiv-http'] 26 | ``` 27 | 28 | ### Peer dependencies 29 | 30 | `hyperactiv/http` depends on external libraries that need to be installed separately. 31 | 32 | ```sh 33 | # The http client 34 | npm i wretch 35 | # For 'normalized' and 'resource' functions. 36 | npm i normaliz 37 | ``` 38 | 39 | ### API 40 | 41 | #### `resource` 42 | 43 | Fetches one or multiple resource(s) from a url, normalize the payload and insert it into the cache. 44 | If the cache already contain a resource with the same id, will attempt to retrieve the resource from the cache instead of performing the request. 45 | Uses [wretch](https://github.com/elbywan/wretch) and [normaliz](https://github.com/elbywan/normaliz) under the hood. 46 | 47 | ```js 48 | const { 49 | data, // If the data was found in the cache, either a single resource object if the id option is used, or an array of resources. 50 | future, // If the data was not found in the cache, a pending Promise that will resolve later with the data fetched from the network. 51 | refetch // Call this function to force-refetch the data from the network 52 | } = resource( 53 | // Name of the resource(s), will be inserted in store['entity']. 54 | 'entity', 55 | // The server route to hit. 56 | '/entity', { 57 | // Id of the resource to fetch. 58 | // If omiitted, useResource will expect an array of elements. 59 | id, 60 | // Either 'cache-first', 'cache-and-network', or 'network-only'. 61 | // - cache-first will fetch the data from the store, and perform a network request only when the data is not found. 62 | // - cache-and-network will fetch the data from the cache, but will perform a network request even if found in the cache. 63 | // - network-only will always perform a network request without looking in the cache. 64 | // Defaults to 'cache-first'. 65 | policy, 66 | // The hyperactiv store. 67 | store, 68 | // An initialized wretch instance, see https://github.com/elbywan/wretch for more details. 69 | client, 70 | // Normalize options, see https://github.com/elbywan/normaliz for more details. 71 | // If omitted, an empty schema will be used. 72 | normalize, 73 | // A function that takes the client configured with the url as an argument, and can modify it before returning it. 74 | // Defaults to the identity function. 75 | beforeRequest, 76 | // A function that takes the network payload as an argument, and can modify it. 77 | // Defaults to the identity function. 78 | afterRequest, 79 | // A function that returns a serialized string, which will be the key in the store mapped to the request. 80 | // Default to (method, url) => `${method}@${url}` 81 | serialize, 82 | // The name of the store root key that will contained the serialized request keys/payloads. 83 | // Defaults to '__requests__' 84 | rootKey, 85 | // The expected body type, which is the name of the wretch function to apply to response. 86 | // Defaults to 'json'. 87 | bodyType 88 | } 89 | ) 90 | ``` 91 | 92 | #### `normalized` 93 | 94 | Same as `resource`, except that it won't try to match the id of the resource in the cache, 95 | and `data` is going to be a slice of the store containing the entities that have been added. 96 | 97 | ```js 98 | const { 99 | data, // A slice of the store that maps to all entities that have been added. 100 | future, // If the data was not found in the cache, a pending Promise that will resolve later with the data fetched from the network. 101 | refetch // Call this function to force-refetch the data from the network 102 | } = normalized( 103 | // The server route to hit. 104 | '/entity', { 105 | // Either 'cache-first', 'cache-and-network', or 'network-only'. 106 | // - cache-first will fetch the data from the store, and perform a network request only when the data is not found. 107 | // - cache-and-network will fetch the data from the cache, but will perform a network request even if found in the cache. 108 | // - network-only will always perform a network request without looking in the cache. 109 | // Defaults to 'cache-first'. 110 | policy, 111 | // The hyperactiv store. 112 | store, 113 | // An initialized wretch instance, see https://github.com/elbywan/wretch for more details. 114 | client, 115 | // Normalize options, see https://github.com/elbywan/normaliz for more details. 116 | // If omitted, an empty schema will be used. 117 | normalize, 118 | // A function that takes the client configured with the url as an argument, and can modify it before returning it. 119 | // Defaults to the identity function. 120 | beforeRequest, 121 | // A function that takes the network payload as an argument, and can modify it. 122 | // Defaults to the identity function. 123 | afterRequest, 124 | // A function that returns a serialized string, which will be the key in the store mapped to the request. 125 | // Default to (method, url) => `${method}@${url}` 126 | serialize, 127 | // The name of the store root key that will contained the serialized request keys/payloads. 128 | // Defaults to '__requests__' 129 | rootKey, 130 | // The expected body type, which is the name of the wretch function to apply to response. 131 | // Defaults to 'json'. 132 | bodyType 133 | } 134 | ) 135 | ``` 136 | 137 | #### `request` 138 | 139 | A simpler hook that will cache requests in the store without post-processing. 140 | 141 | ```js 142 | const { 143 | data, // The (potentially cached) payload from the server. 144 | future, // If the data was not found in the cache, a pending Promise that will resolve later with the data fetched from the network. 145 | refetch // Call this function to refetch the data from the network 146 | } = request( 147 | // The server route to hit. 148 | '/entity', { 149 | // Either 'cache-first', 'cache-and-network', or 'network-only'. 150 | // - cache-first will fetch the data from the store, and perform a network request only when the data is not found. 151 | // - cache-and-network will fetch the data from the cache, but will perform a network request even if found in the cache. 152 | // - network-only will always perform a network request without looking in the cache. 153 | // Defaults to 'cache-first'. 154 | policy, 155 | // The hyperactiv store. 156 | store, 157 | // An initialized wretch instance, see https://github.com/elbywan/wretch for more details. 158 | client, 159 | // A function that takes the client configured with the url as an argument, and can modify it before returning it. 160 | // Defaults to the identity function. 161 | beforeRequest, 162 | // A function that takes the network payload as an argument, and can modify it. 163 | // Defaults to the identity function. 164 | afterRequest, 165 | // A function that returns a serialized string, which will be the key in the store mapped to the request. 166 | // Default to (method, url) => `${method}@${url}` 167 | serialize, 168 | // The name of the store root key that will contained the serialized request keys/payloads. 169 | // Defaults to '__requests__' 170 | rootKey, 171 | // The expected body type, which is the name of the wretch function to apply to response. 172 | // Defaults to 'json'. 173 | bodyType 174 | } 175 | ) 176 | ``` 177 | -------------------------------------------------------------------------------- /src/http/index.js: -------------------------------------------------------------------------------- 1 | export { request } from './request.js' 2 | export { normalized } from './normalized.js' 3 | export { resource } from './resource.js' 4 | -------------------------------------------------------------------------------- /src/http/normalized.js: -------------------------------------------------------------------------------- 1 | import wretch from 'wretch' 2 | import { normaliz } from 'normaliz' 3 | 4 | import { identity, defaultSerialize, defaultRootKey, normalizedOperations } from './tools.js' 5 | 6 | export function normalized(url, { 7 | store, 8 | normalize, 9 | client = wretch(), 10 | beforeRequest = identity, 11 | afterRequest = identity, 12 | rootKey = defaultRootKey, 13 | serialize = defaultSerialize, 14 | bodyType = 'json', 15 | policy = 'cache-first' 16 | }) { 17 | const configuredClient = beforeRequest(client.url(url)) 18 | const storeKey = serialize('get', configuredClient._url) 19 | if(!store[rootKey]) { 20 | store[rootKey] = {} 21 | } 22 | const storedMappings = store[rootKey][storeKey] 23 | const cacheLookup = policy !== 'network-only' 24 | const data = 25 | cacheLookup && 26 | storedMappings && 27 | normalizedOperations.read(storedMappings, store) || 28 | null 29 | 30 | function refetch() { 31 | return configuredClient 32 | .get() 33 | // eslint-disable-next-line no-unexpected-multiline 34 | [bodyType](body => afterRequest(body)) 35 | .then(result => { 36 | const normalizedData = normaliz(result, normalize) 37 | store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { 38 | mappings[entity] = Object.keys(dataById) 39 | return mappings 40 | }, {}) 41 | normalizedOperations.write(normalizedData, store) 42 | const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) 43 | return storeSlice 44 | }) 45 | } 46 | 47 | const future = policy !== 'cache-first' || !data ? refetch() : null 48 | 49 | return { 50 | data, 51 | refetch, 52 | future 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/http/request.js: -------------------------------------------------------------------------------- 1 | import wretch from 'wretch' 2 | 3 | import { identity, defaultSerialize, defaultRootKey } from './tools.js' 4 | 5 | export function request(url, { 6 | store, 7 | client = wretch(), 8 | beforeRequest = identity, 9 | afterRequest = identity, 10 | rootKey = defaultRootKey, 11 | serialize = defaultSerialize, 12 | bodyType = 'json', 13 | policy = 'cache-first' 14 | }) { 15 | const configuredClient = beforeRequest(client.url(url)) 16 | const storeKey = serialize('get', configuredClient._url) 17 | if(!store[rootKey]) { 18 | store[rootKey] = {} 19 | } 20 | const storedData = store[rootKey][storeKey] 21 | const cacheLookup = policy !== 'network-only' 22 | const data = cacheLookup && storedData || null 23 | 24 | function refetch() { 25 | return configuredClient 26 | .get() 27 | // eslint-disable-next-line no-unexpected-multiline 28 | [bodyType](body => afterRequest(body)) 29 | .then(result => { 30 | store[rootKey][storeKey] = result 31 | return result 32 | }) 33 | } 34 | 35 | const future = policy !== 'cache-first' || !data ? refetch() : null 36 | 37 | return { 38 | data, 39 | refetch, 40 | future 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/http/resource.js: -------------------------------------------------------------------------------- 1 | import { normalized } from './normalized.js' 2 | 3 | function formatData(data, entity, id) { 4 | return ( 5 | data ? 6 | id !== null ? 7 | data[entity] && data[entity][id] : 8 | data[entity] && Object.values(data[entity]) : 9 | data 10 | ) 11 | } 12 | 13 | export function resource(entity, url, { 14 | id = null, 15 | store, 16 | normalize, 17 | client, 18 | beforeRequest, 19 | afterRequest, 20 | serialize, 21 | rootKey, 22 | bodyType, 23 | policy = 'cache-first' 24 | }) { 25 | const storedEntity = id && store[entity] && store[entity][id] 26 | 27 | const { 28 | data, 29 | future, 30 | refetch: normalizedRefetch 31 | } = normalized(url, { 32 | store, 33 | normalize: { 34 | schema: [], 35 | ...normalize, 36 | entity 37 | }, 38 | client, 39 | beforeRequest, 40 | afterRequest, 41 | serialize, 42 | rootKey, 43 | bodyType, 44 | policy 45 | }) 46 | 47 | const refetch = () => normalizedRefetch().then(data => 48 | formatData(data, entity, id) 49 | ) 50 | 51 | return { 52 | data: policy !== 'network-only' && storedEntity || formatData(data, entity, id), 53 | future: future && future.then(data => formatData(data, entity, id)) || null, 54 | refetch 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/http/tools.js: -------------------------------------------------------------------------------- 1 | export const defaultRootKey = '__requests__' 2 | export const defaultSerialize = (method, url) => `${method}@${url}` 3 | export const identity = _ => _ 4 | 5 | export const normalizedOperations = { 6 | read(mappings, store) { 7 | const storeFragment = {} 8 | Object.entries(mappings).forEach(([ entity, ids ]) => { 9 | storeFragment[entity] = {} 10 | ids.forEach(key => { 11 | storeFragment[entity][key] = store[entity] && store[entity][key] || null 12 | }) 13 | }) 14 | return storeFragment 15 | }, 16 | write(normalizedData, store) { 17 | Object.entries(normalizedData).forEach(([ entity, entityData ]) => { 18 | if(!store[entity]) { 19 | store[entity] = {} 20 | } 21 | 22 | Object.entries(entityData).forEach(([ key, value ]) => { 23 | if(store[entity][key]) { 24 | if(typeof store[entity][key] === 'object' && typeof value === 'object') { 25 | Object.entries(value).forEach(([k, v]) => { 26 | store[entity][key][k] = v 27 | }) 28 | } else { 29 | store[entity][key] = value 30 | } 31 | } else { 32 | store[entity][key] = value 33 | } 34 | }) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { observe } from './observe.js' 2 | import { computed } from './computed.js' 3 | import { dispose } from './dispose.js' 4 | import { process } from './batcher.js' 5 | 6 | export default { 7 | observe, 8 | computed, 9 | dispose, 10 | batch: process 11 | } 12 | -------------------------------------------------------------------------------- /src/observe.js: -------------------------------------------------------------------------------- 1 | import { 2 | isObj, 3 | defineBubblingProperties, 4 | getInstanceMethodKeys, 5 | setHiddenKey 6 | } from './tools.js' 7 | import { data } from './data.js' 8 | import { enqueue, __batched } from './batcher.js' 9 | 10 | const { computedStack, trackerSymbol } = data 11 | 12 | const observedSymbol = Symbol('__observed') 13 | 14 | /** 15 | * @typedef {Object} Options - Observe options. 16 | * @property {string[]} [props] - Observe only the properties listed. 17 | * @property {string[]} [ignore] - Ignore the properties listed. 18 | * @property {boolean | number} [batch] - 19 | * Batch computed properties calls, wrapping them in a queueMicrotask and 20 | * executing them in a new context and preventing excessive calls. 21 | * If batch is an integer, the calls will be debounced by the value in milliseconds using setTimemout. 22 | * @prop {boolean} [deep] - Recursively observe nested objects and when setting new properties. 23 | * @prop {boolean} [bind] - Automatically bind methods to the observed object. 24 | */ 25 | 26 | /** 27 | * Observes an object or an array and returns a proxified version which reacts on mutations. 28 | * 29 | * @template O 30 | * @param {O} obj - The object to observe. 31 | * @param {Options} options - Options 32 | * @returns {O} - A proxy wrapping the object. 33 | */ 34 | export function observe(obj, options = {}) { 35 | // 'deep' is slower but reasonable; 'shallow' a performance enhancement but with side-effects 36 | const { 37 | props, 38 | ignore, 39 | batch, 40 | deep = true, 41 | bubble, 42 | bind 43 | } = options 44 | 45 | // Ignore if the object is already observed 46 | if(obj[observedSymbol]) { 47 | return obj 48 | } 49 | 50 | // If the prop is explicitely not excluded 51 | const isWatched = (prop, value) => 52 | prop !== observedSymbol && 53 | ( 54 | !props || 55 | props instanceof Array && props.includes(prop) || 56 | typeof props === 'function' && props(prop, value) 57 | ) && ( 58 | !ignore || 59 | !(ignore instanceof Array && ignore.includes(prop)) && 60 | !(typeof ignore === 'function' && ignore(prop, value)) 61 | ) 62 | 63 | // If the deep flag is set, observe nested objects/arrays 64 | if(deep) { 65 | Object.entries(obj).forEach(function([key, val]) { 66 | if(isObj(val) && isWatched(key, val)) { 67 | obj[key] = observe(val, options) 68 | // If bubble is set, we add keys to the object used to bubble up the mutation 69 | if(bubble) { 70 | defineBubblingProperties(obj[key], key, obj) 71 | } 72 | } 73 | }) 74 | } 75 | 76 | // For each observed object, each property is mapped with a set of computed functions depending on this property. 77 | // Whenever a property is set, we re-run each one of the functions stored inside the matching Set. 78 | const propertiesMap = new Map() 79 | 80 | // Proxify the object in order to intercept get/set on props 81 | const proxy = new Proxy(obj, { 82 | get(_, prop) { 83 | if(prop === observedSymbol) 84 | return true 85 | 86 | // If the prop is watched 87 | if(isWatched(prop, obj[prop])) { 88 | // If a computed function is being run 89 | if(computedStack.length) { 90 | const computedFn = computedStack[0] 91 | // Tracks object and properties accessed during the function call 92 | const tracker = computedFn[trackerSymbol] 93 | if(tracker) { 94 | let trackerSet = tracker.get(obj) 95 | if(!trackerSet) { 96 | trackerSet = new Set() 97 | tracker.set(obj, trackerSet) 98 | } 99 | trackerSet.add(prop) 100 | } 101 | // Link the computed function and the property being accessed 102 | let propertiesSet = propertiesMap.get(prop) 103 | if(!propertiesSet) { 104 | propertiesSet = new Set() 105 | propertiesMap.set(prop, propertiesSet) 106 | } 107 | propertiesSet.add(computedFn) 108 | } 109 | } 110 | 111 | return obj[prop] 112 | }, 113 | set(_, prop, value) { 114 | if(prop === '__handler') { 115 | // Don't track bubble handlers 116 | setHiddenKey(obj, '__handler', value) 117 | } else if(!isWatched(prop, value)) { 118 | // If the prop is ignored 119 | obj[prop] = value 120 | } else if(Array.isArray(obj) && prop === 'length' || obj[prop] !== value) { 121 | // If the new/old value are not equal 122 | const deeper = deep && isObj(value) 123 | 124 | // Remove bubbling infrastructure and pass old value to handlers 125 | const oldValue = obj[prop] 126 | if(isObj(oldValue)) 127 | delete obj[prop] 128 | 129 | // If the deep flag is set we observe the newly set value 130 | obj[prop] = deeper ? observe(value, options) : value 131 | 132 | // Co-opt assigned object into bubbling if appropriate 133 | if(deeper && bubble) { 134 | defineBubblingProperties(obj[prop], prop, obj) 135 | } 136 | 137 | const ancestry = [ prop ] 138 | let parent = obj 139 | while(parent) { 140 | // If a handler explicitly returns 'false' then stop propagation 141 | if(parent.__handler && parent.__handler(ancestry, value, oldValue, proxy) === false) { 142 | break 143 | } 144 | // Continue propagation, traversing the mutated property's object hierarchy & call any __handlers along the way 145 | if(parent.__key && parent.__parent) { 146 | ancestry.unshift(parent.__key) 147 | parent = parent.__parent 148 | } else { 149 | parent = null 150 | } 151 | } 152 | 153 | const dependents = propertiesMap.get(prop) 154 | if(dependents) { 155 | // Retrieve the computed functions depending on the prop 156 | for(const dependent of dependents) { 157 | const tracker = dependent[trackerSymbol] 158 | const trackedObj = tracker && tracker.get(obj) 159 | const tracked = trackedObj && trackedObj.has(prop) 160 | // If the function has been disposed or if the prop has not been used 161 | // during the latest function call, delete the function reference 162 | if(dependent.__disposed || tracker && !tracked) { 163 | dependents.delete(dependent) 164 | } else if(dependent !== computedStack[0]) { 165 | // Run the computed function 166 | if(typeof batch !== 'undefined' && batch !== false) { 167 | enqueue(dependent, batch) 168 | dependent[__batched] = true 169 | } else { 170 | dependent() 171 | } 172 | } 173 | } 174 | } 175 | } 176 | 177 | return true 178 | } 179 | }) 180 | 181 | if(bind) { 182 | // Need this for binding es6 classes methods which are stored in the object prototype 183 | getInstanceMethodKeys(obj).forEach(key => obj[key] = obj[key].bind(proxy)) 184 | } 185 | 186 | return proxy 187 | } 188 | -------------------------------------------------------------------------------- /src/react/assets/counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elbywan/hyperactiv/9fdf71f39446bfcbe4a44189b5005eea4cee65bf/src/react/assets/counter.gif -------------------------------------------------------------------------------- /src/react/context/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOMServer from 'react-dom/server' 3 | 4 | export const HyperactivContext = React.createContext({ 5 | store: null, 6 | client: null 7 | }) 8 | 9 | export const SSRContext = React.createContext(null) 10 | 11 | export function HyperactivProvider({ children, store, client }) { 12 | return React.createElement( 13 | HyperactivContext.Provider, 14 | { 15 | value: { store, client } 16 | }, 17 | children 18 | ) 19 | } 20 | 21 | export function SSRProvider({ children, promises }) { 22 | return React.createElement( 23 | SSRContext.Provider, 24 | { 25 | value: promises 26 | }, 27 | children 28 | ) 29 | } 30 | 31 | export async function preloadData(jsx, { depth = null } = {}) { 32 | let loopIterations = 0 33 | const promises = [] 34 | while(loopIterations === 0 || promises.length > 0) { 35 | if(depth !== null && loopIterations >= depth) 36 | break 37 | promises.length = 0 38 | ReactDOMServer.renderToStaticMarkup( 39 | React.createElement( 40 | SSRProvider, 41 | { promises }, 42 | jsx 43 | ) 44 | ) 45 | await Promise.all(promises) 46 | loopIterations++ 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/react/hooks/context.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { HyperactivContext } from '../context/index.js' 3 | 4 | export function useStore() { 5 | const context = useContext(HyperactivContext) 6 | return context && context.store 7 | } 8 | 9 | export function useClient() { 10 | const context = useContext(HyperactivContext) 11 | return context && context.client 12 | } 13 | -------------------------------------------------------------------------------- /src/react/hooks/dependencies.js: -------------------------------------------------------------------------------- 1 | export default new Proxy({ 2 | references: { 3 | wretch: null, 4 | normaliz: null 5 | } 6 | }, { 7 | get(target, property) { 8 | if(target[property]) 9 | return target[property] 10 | throw 'Hook dependencies are not registered!\nUse `.setHooksDependencies({ wretch, normaliz }) to set them.' 11 | } 12 | }) -------------------------------------------------------------------------------- /src/react/hooks/index.js: -------------------------------------------------------------------------------- 1 | import dependencies from './dependencies.js' 2 | 3 | export function setHooksDependencies({ wretch, normaliz }) { 4 | if(wretch) dependencies.references.wretch = wretch 5 | if(normaliz) dependencies.references.normaliz = normaliz 6 | } 7 | 8 | export * from './useNormalizedRequest.js' 9 | export * from './useRequest.js' 10 | export * from './useResource.js' 11 | export * from './context.js' 12 | -------------------------------------------------------------------------------- /src/react/hooks/useNormalizedRequest.js: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect, useContext, useRef } from 'react' 2 | 3 | import { identity, defaultSerialize, defaultRootKey, normalizedOperations } from '../../http/tools.js' 4 | import { HyperactivContext, SSRContext } from '../context/index.js' 5 | import dependencies from './dependencies.js' 6 | 7 | export function useNormalizedRequest(url, { 8 | store, 9 | normalize, 10 | client, 11 | skip = () => false, 12 | beforeRequest = identity, 13 | afterRequest = identity, 14 | rootKey = defaultRootKey, 15 | serialize = defaultSerialize, 16 | bodyType = 'json', 17 | policy = 'cache-first', 18 | ssr = true 19 | }) { 20 | const contextValue = useContext(HyperactivContext) 21 | const ssrContext = ssr && useContext(SSRContext) 22 | store = contextValue && contextValue.store || store 23 | client = contextValue && contextValue.client || client || dependencies.references.wretch() 24 | 25 | const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) 26 | const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) 27 | if(!store[rootKey]) { 28 | store[rootKey] = {} 29 | } 30 | const storedMappings = store[rootKey][storeKey] 31 | 32 | const cacheLookup = policy !== 'network-only' 33 | 34 | const [ error, setError ] = useState(null) 35 | const [ loading, setLoading ] = useState( 36 | !cacheLookup || 37 | !storedMappings 38 | ) 39 | const [ networkData, setNetworkData ] = useState(null) 40 | const data = 41 | cacheLookup ? 42 | storedMappings && 43 | normalizedOperations.read(storedMappings, store) : 44 | networkData 45 | 46 | const unmounted = useRef(false) 47 | useEffect(() => () => unmounted.current = false, []) 48 | const pendingRequests = useRef([]) 49 | 50 | function refetch(noState) { 51 | if(!noState && !unmounted.current) { 52 | setLoading(true) 53 | setError(null) 54 | setNetworkData(null) 55 | } 56 | const promise = configuredClient 57 | .get() 58 | // eslint-disable-next-line no-unexpected-multiline 59 | [bodyType](body => afterRequest(body)) 60 | .then(result => { 61 | const normalizedData = dependencies.references.normaliz(result, normalize) 62 | store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { 63 | mappings[entity] = Object.keys(dataById) 64 | return mappings 65 | }, {}) 66 | normalizedOperations.write(normalizedData, store) 67 | const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) 68 | pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) 69 | if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { 70 | setNetworkData(storeSlice) 71 | setLoading(false) 72 | } 73 | return storeSlice 74 | }) 75 | .catch(error => { 76 | pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) 77 | if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { 78 | setError(error) 79 | setLoading(false) 80 | } 81 | if(ssrContext) 82 | throw error 83 | }) 84 | 85 | pendingRequests.current.push(promise) 86 | if(ssrContext) { 87 | ssrContext.push(promise) 88 | } 89 | return promise 90 | } 91 | 92 | function checkAndRefetch(noState = false) { 93 | if( 94 | !skip() && 95 | !error && 96 | (policy !== 'cache-first' || !data) 97 | ) { 98 | refetch(noState) 99 | } 100 | } 101 | 102 | useEffect(function() { 103 | checkAndRefetch() 104 | }, [ storeKey, skip() ]) 105 | 106 | if(ssrContext) { 107 | checkAndRefetch(true) 108 | } 109 | 110 | return skip() ? { 111 | data: null, 112 | error: null, 113 | loading: false, 114 | refetch 115 | } : { 116 | loading, 117 | data, 118 | error, 119 | refetch 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/react/hooks/useRequest.js: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect, useContext, useRef } from 'react' 2 | import { identity, defaultSerialize, defaultRootKey } from '../../http/tools.js' 3 | import { HyperactivContext, SSRContext } from '../context/index.js' 4 | import dependencies from './dependencies.js' 5 | 6 | export function useRequest(url, { 7 | store, 8 | client, 9 | skip = () => false, 10 | beforeRequest = identity, 11 | afterRequest = identity, 12 | rootKey = defaultRootKey, 13 | serialize = defaultSerialize, 14 | bodyType = 'json', 15 | policy = 'cache-first', 16 | ssr = true 17 | }) { 18 | const contextValue = useContext(HyperactivContext) 19 | const ssrContext = ssr && useContext(SSRContext) 20 | store = contextValue && contextValue.store || store 21 | client = contextValue && contextValue.client || client || dependencies.references.wretch() 22 | 23 | const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) 24 | const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) 25 | if(!store[rootKey]) { 26 | store[rootKey] = {} 27 | } 28 | const storedData = store[rootKey][storeKey] 29 | 30 | const cacheLookup = policy !== 'network-only' 31 | 32 | const [ error, setError ] = useState(null) 33 | const [ loading, setLoading ] = useState( 34 | !cacheLookup || 35 | !storedData 36 | ) 37 | const [ networkData, setNetworkData ] = useState(null) 38 | const data = cacheLookup ? storedData : networkData 39 | 40 | const unmounted = useRef(false) 41 | useEffect(() => () => unmounted.current = false, []) 42 | const pendingRequests = useRef([]) 43 | 44 | function refetch(noState) { 45 | if(!noState && !unmounted.current) { 46 | setLoading(true) 47 | setError(null) 48 | setNetworkData(null) 49 | } 50 | const promise = configuredClient 51 | .get() 52 | // eslint-disable-next-line no-unexpected-multiline 53 | [bodyType](body => afterRequest(body)) 54 | .then(result => { 55 | store[rootKey][storeKey] = result 56 | pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) 57 | if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { 58 | setNetworkData(result) 59 | setLoading(false) 60 | } 61 | return result 62 | }) 63 | .catch(error => { 64 | pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) 65 | if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { 66 | setError(error) 67 | setLoading(false) 68 | } 69 | if(ssrContext) 70 | throw error 71 | }) 72 | 73 | pendingRequests.current.push(promise) 74 | if(ssrContext) { 75 | ssrContext.push(promise) 76 | } 77 | return promise 78 | } 79 | 80 | function checkAndRefetch(noState = false) { 81 | if( 82 | !skip() && 83 | !error && 84 | (policy !== 'cache-first' || !data) 85 | ) { 86 | refetch(noState) 87 | } 88 | } 89 | 90 | useEffect(function() { 91 | checkAndRefetch() 92 | }, [ storeKey, skip() ]) 93 | 94 | if(ssrContext) { 95 | checkAndRefetch(true) 96 | } 97 | 98 | return skip() ? { 99 | data: null, 100 | error: null, 101 | loading: false, 102 | refetch 103 | } : { 104 | loading, 105 | data, 106 | error, 107 | refetch 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/react/hooks/useResource.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useContext } from 'react' 2 | import { useNormalizedRequest } from './useNormalizedRequest.js' 3 | import { HyperactivContext } from '../context/index.js' 4 | 5 | function formatData(data, entity, id) { 6 | return ( 7 | data ? 8 | id !== null ? 9 | data[entity] && data[entity][id] : 10 | data[entity] && Object.values(data[entity]) : 11 | data 12 | ) 13 | } 14 | 15 | export function useResource(entity, url, { 16 | id = null, 17 | store, 18 | normalize, 19 | client, 20 | skip: skipProp = () => false, 21 | beforeRequest, 22 | afterRequest, 23 | serialize, 24 | rootKey, 25 | bodyType, 26 | policy = 'cache-first', 27 | ssr = true 28 | }) { 29 | const contextValue = useContext(HyperactivContext) 30 | store = contextValue && contextValue.store || store 31 | const storedEntity = id && store[entity] && store[entity][id] 32 | 33 | const { 34 | data, 35 | loading, 36 | error, 37 | refetch: normalizedRefetch 38 | } = useNormalizedRequest(url, { 39 | store, 40 | normalize: { 41 | schema: [], 42 | ...normalize, 43 | entity 44 | }, 45 | client, 46 | skip() { 47 | return ( 48 | policy === 'cache-first' && storedEntity || 49 | skipProp() 50 | ) 51 | }, 52 | beforeRequest, 53 | afterRequest, 54 | serialize, 55 | rootKey, 56 | bodyType, 57 | policy, 58 | ssr 59 | }) 60 | 61 | const formattedData = useMemo(() => 62 | formatData(data, entity, id) 63 | , [data, entity, id]) 64 | 65 | const refetch = () => normalizedRefetch().then(data => 66 | formatData(data, entity, id) 67 | ) 68 | 69 | if(policy !== 'network-only' && storedEntity) { 70 | return { 71 | data: storedEntity, 72 | loading: false, 73 | error: null, 74 | refetch 75 | } 76 | } 77 | 78 | return { 79 | data: formattedData, 80 | loading, 81 | error, 82 | refetch 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/react/index.js: -------------------------------------------------------------------------------- 1 | import hyperactiv from '../../src/index.js' 2 | 3 | export { watch } from './watchHoc.js' 4 | export { Watch } from './watchComponent.js' 5 | export * from './hooks/index.js' 6 | export * from './context/index.js' 7 | 8 | export const store = function(obj, options = {}) { 9 | return hyperactiv.observe(obj, Object.assign({ deep: true, batch: false }, options)) 10 | } 11 | -------------------------------------------------------------------------------- /src/react/watchComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import hyperactiv from '../../src/index.js' 3 | const { computed, dispose } = hyperactiv 4 | 5 | export class Watch extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this._callback = () => { 9 | this._mounted && 10 | this.forceUpdate.bind(this)() 11 | } 12 | this.computeRenderMethod(props.render) 13 | } 14 | componentWillUnmount() { 15 | this._mounted = false 16 | dispose(this._callback) 17 | } 18 | componentDidMount() { 19 | this._mounted = true 20 | } 21 | computeRenderMethod(newRender) { 22 | if(!!newRender && this._currentRender !== newRender) { 23 | this._currentRender = computed(newRender, { 24 | autoRun: false, 25 | callback: this._callback 26 | }) 27 | } 28 | } 29 | render() { 30 | const { render } = this.props 31 | this.computeRenderMethod(render) 32 | return this._currentRender && this._currentRender() || null 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/react/watchHoc.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import hyperactiv from '../../src/index.js' 3 | import { useStore } from './hooks/context.js' 4 | const { computed, dispose } = hyperactiv 5 | 6 | /** 7 | * Wraps a class component and automatically updates it when the store mutates. 8 | * @param {*} Component The component to wrap 9 | */ 10 | const watchClassComponent = Component => new Proxy(Component, { 11 | construct: function(Target, argumentsList) { 12 | // Create a new Component instance 13 | const instance = new Target(...argumentsList) 14 | // Ensures that the forceUpdate in correctly bound 15 | instance.forceUpdate = instance.forceUpdate.bind(instance) 16 | // Monkey patch the componentWillUnmount method to do some clean up on destruction 17 | const originalUnmount = 18 | typeof instance.componentWillUnmount === 'function' && 19 | instance.componentWillUnmount.bind(instance) 20 | instance.componentWillUnmount = function(...args) { 21 | dispose(instance.forceUpdate) 22 | if(originalUnmount) { 23 | originalUnmount(...args) 24 | } 25 | } 26 | // Return a proxified Component 27 | return new Proxy(instance, { 28 | get: function(target, property) { 29 | if(property === 'render') { 30 | // Compute the render function and forceUpdate on changes 31 | return computed(target.render.bind(target), { autoRun: false, callback: instance.forceUpdate }) 32 | } 33 | return target[property] 34 | } 35 | }) 36 | } 37 | }) 38 | 39 | /** 40 | * Wraps a functional component and automatically updates it when the store mutates. 41 | * @param {*} component The component to wrap 42 | */ 43 | function watchFunctionalComponent(component) { 44 | const wrapper = props => { 45 | const [, forceUpdate] = React.useReducer(x => x + 1, 0) 46 | const store = useStore() 47 | const injectedProps = props.store ? props : { 48 | ...props, 49 | store 50 | } 51 | const [child, setChild] = React.useState(null) 52 | React.useEffect(function onMount() { 53 | const callback = () => forceUpdate() 54 | setChild(() => computed(component, { 55 | autoRun: false, 56 | callback 57 | })) 58 | return function onUnmount() { 59 | dispose(callback) 60 | } 61 | }, []) 62 | return child ? child(injectedProps) : component(injectedProps) 63 | } 64 | wrapper.displayName = component.displayName || component.name 65 | return wrapper 66 | } 67 | 68 | /** 69 | * Wraps a component and automatically updates it when the store mutates. 70 | * @param {*} Component The component to wrap 71 | */ 72 | export const watch = Component => 73 | typeof Component === 'function' && 74 | (!Component.prototype || !Component.prototype.isReactComponent) ? 75 | watchFunctionalComponent(Component) : 76 | watchClassComponent(Component) 77 | -------------------------------------------------------------------------------- /src/tools.js: -------------------------------------------------------------------------------- 1 | const BIND_IGNORED = [ 2 | 'String', 3 | 'Number', 4 | 'Object', 5 | 'Array', 6 | 'Boolean', 7 | 'Date' 8 | ] 9 | 10 | export function isObj(object) { return object && typeof object === 'object' } 11 | export function setHiddenKey(object, key, value) { 12 | Object.defineProperty(object, key, { value, enumerable: false, configurable: true }) 13 | } 14 | export function defineBubblingProperties(object, key, parent) { 15 | setHiddenKey(object, '__key', key) 16 | setHiddenKey(object, '__parent', parent) 17 | } 18 | export function getInstanceMethodKeys(object) { 19 | return ( 20 | Object 21 | .getOwnPropertyNames(object) 22 | .concat( 23 | Object.getPrototypeOf(object) && 24 | BIND_IGNORED.indexOf(Object.getPrototypeOf(object).constructor.name) < 0 ? 25 | Object.getOwnPropertyNames(Object.getPrototypeOf(object)) : 26 | [] 27 | ) 28 | .filter(prop => prop !== 'constructor' && typeof object[prop] === 'function') 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/websocket/README.md: -------------------------------------------------------------------------------- 1 | # hyperactiv/websocket 2 | 3 | ### Hyperactiv websocket implementation. 4 | 5 | Establishing a one-way data sync over WebSocket is easy. 6 | 7 | ### Import 8 | 9 | ```js 10 | import websocket from 'hyperactiv/websocket' 11 | ``` 12 | 13 | Alternatively, if you prefer script tags : 14 | 15 | ```html 16 | 17 | ``` 18 | 19 | ```js 20 | const hyperactivWebsocket = window['hyperactiv-websocket'] 21 | ``` 22 | 23 | ### Usage 24 | 25 | #### WebSocket Server 26 | 27 | ```javascript 28 | const WebSocket = require('ws') 29 | const extendWebSocketServerWithHostMethod = require('hyperactiv/websocket/server').server 30 | const wss = extendWebSocketServerWithHostMethod(new WebSocket.Server({ port: 8080 })) 31 | const hostedObject = wss.host({}) 32 | ``` 33 | 34 | #### Express Server 35 | 36 | ```javascript 37 | const http = require('http') 38 | const express = require('express') 39 | const WebSocket = require('ws') 40 | 41 | const extendWebSocketServerWithHostMethod = require('hyperactiv/websocket/server').server 42 | 43 | const app = express() 44 | const server = http.createServer(app) 45 | const wss = extendWebSocketServerWithHostMethod(new WebSocket.Server({ server })) 46 | server.listen(8080) 47 | 48 | const hostedObject = wss.host({}) 49 | ``` 50 | 51 | #### Node.js Client 52 | 53 | ```javascript 54 | const WebSocket = require('ws') 55 | const subscribeToHostedObject = require('hyperactiv/websocket/server').client 56 | const remoteObject = subscribeToHostedObject(new WebSocket("ws://localhost:8080")) 57 | ``` 58 | 59 | #### Browser Client 60 | 61 | ```html 62 | 63 | 64 | 65 | 66 | 67 | 68 | Check developer console for "remoteObject" 69 | 70 | 71 | ``` 72 | 73 | ### API 74 | 75 | #### wss.host(object, observeOptions) 76 | 77 | Observes the object server-side, and creates the websocket server that will listen for RPC calls and propagate changes to clients. 78 | 79 | #### wss.client(ws, object) 80 | 81 | Reflect changes made to the server hosted object into the argument object. 82 | 83 | #### __remoteMethods 84 | 85 | Use the `__remoteMethods` key to mark methods and make them callable by the clients. 86 | Alternatively, you can use the `autoExportMethods` option to automatically mark every method as callable. 87 | 88 | **Example** 89 | 90 | ```js 91 | /* Server side */ 92 | 93 | const hostedObject = { 94 | a: 1, 95 | getAPlusOne(number) { 96 | return hostedObject.a + 1 97 | }, 98 | // Mark getAPlusOne as callable by the clients 99 | __remoteMethods: [ 'getAPlusOne' ] 100 | } 101 | 102 | wss.host(hostedObject) 103 | 104 | // OR, omit the __remoteMethods props and call: 105 | wss.host(hostedObject, { autoExportMethods: true }) 106 | 107 | /* Client side */ 108 | 109 | const a = await clientObject.getAPlusOne(1) 110 | // a = 2 111 | ``` 112 | -------------------------------------------------------------------------------- /src/websocket/browser.js: -------------------------------------------------------------------------------- 1 | import { writeHandler } from '../handlers/write.js' 2 | 3 | export default (url, obj, debug, timeout) => { 4 | const cbs = {}, ws = new WebSocket(url || 'ws://localhost:8080'), update = writeHandler(obj) 5 | let id = 0 6 | ws.addEventListener('message', msg => { 7 | msg = JSON.parse(msg.data) 8 | if(debug) 9 | debug(msg) 10 | if(msg.type === 'sync') { 11 | Object.assign(obj, msg.state) 12 | if(Array.isArray(msg.methods)) { 13 | msg.methods.forEach(keys => update(keys, async (...args) => { 14 | ws.send(JSON.stringify({ type: 'call', keys: keys, args: args, request: ++id })) 15 | return new Promise((resolve, reject) => { 16 | cbs[id] = { resolve, reject } 17 | setTimeout(() => { 18 | delete cbs[id] 19 | reject(new Error('Timeout on call to ' + keys)) 20 | }, timeout || 15000) 21 | }) 22 | })) 23 | } 24 | } else if(msg.type === 'update') { 25 | update(msg.keys, msg.value) 26 | } else if(msg.type === 'response') { 27 | if(msg.error) { 28 | cbs[msg.request].reject(msg.error) 29 | } else { 30 | cbs[msg.request].resolve(msg.result) 31 | } 32 | delete cbs[msg.request] 33 | } 34 | }) 35 | 36 | ws.addEventListener('open', () => ws.send('sync')) 37 | } -------------------------------------------------------------------------------- /src/websocket/server.js: -------------------------------------------------------------------------------- 1 | import hyperactiv from '../index.js' 2 | import handlers from '../handlers/index.js' 3 | 4 | const { observe } = hyperactiv 5 | 6 | function send(socket, obj) { 7 | socket.send(JSON.stringify(obj)) 8 | } 9 | 10 | function findRemoteMethods({ target, autoExportMethods, stack = [], methods = [] }) { 11 | if(typeof target === 'object') { 12 | if(autoExportMethods) { 13 | Object.entries(target).forEach(([key, value]) => { 14 | if(typeof value === 'function') { 15 | stack.push(key) 16 | methods.push(stack.slice(0)) 17 | stack.pop() 18 | } 19 | 20 | }) 21 | } else if(target.__remoteMethods) { 22 | if(!Array.isArray(target.__remoteMethods)) 23 | target.__remoteMethods = [ target.__remoteMethods ] 24 | target.__remoteMethods.forEach(method => { 25 | stack.push(method) 26 | methods.push(stack.slice(0)) 27 | stack.pop() 28 | }) 29 | } 30 | 31 | Object.keys(target).forEach(key => { 32 | stack.push(key) 33 | findRemoteMethods({ target: target[key], autoExportMethods, stack, methods }) 34 | stack.pop() 35 | }) 36 | } 37 | 38 | return methods 39 | } 40 | 41 | function server(wss) { 42 | wss.host = (data, options) => { 43 | options = Object.assign({}, { deep: true, batch: true, bubble: true }, options || {}) 44 | const autoExportMethods = options.autoExportMethods 45 | const obj = observe(data || {}, options) 46 | obj.__handler = (keys, value, old) => { 47 | wss.clients.forEach(client => { 48 | if(client.readyState === 1) { 49 | send(client, { type: 'update', keys: keys, value: value, old: old }) 50 | } 51 | }) 52 | } 53 | 54 | wss.on('connection', socket => { 55 | socket.on('message', async message => { 56 | if(message === 'sync') { 57 | send(socket, { type: 'sync', state: obj, methods: findRemoteMethods({ target: obj, autoExportMethods }) }) 58 | } else { 59 | message = JSON.parse(message) 60 | // if(message.type && message.type === 'call') { 61 | let cxt = obj, result = null, error = null 62 | message.keys.forEach(key => cxt = cxt[key]) 63 | try { 64 | result = await cxt(...message.args) 65 | } catch(err) { 66 | error = err.message 67 | } 68 | send(socket, { type: 'response', result, error, request: message.request }) 69 | // } 70 | } 71 | }) 72 | }) 73 | return obj 74 | } 75 | return wss 76 | } 77 | 78 | function client(ws, obj = {}) { 79 | let id = 1 80 | const cbs = {} 81 | ws.on('message', msg => { 82 | msg = JSON.parse(msg) 83 | if(msg.type === 'sync') { 84 | Object.assign(obj, msg.state) 85 | msg.methods.forEach(keys => handlers.write(obj)(keys, async (...args) => 86 | new Promise((resolve, reject) => { 87 | cbs[id] = { resolve, reject } 88 | send(ws, { type: 'call', keys: keys, args: args, request: id++ }) 89 | }) 90 | )) 91 | } else if(msg.type === 'update') { 92 | handlers.write(obj)(msg.keys, msg.value) 93 | } else /* if(msg.type === 'response') */{ 94 | if(msg.error) { 95 | cbs[msg.request].reject(msg.error) 96 | } else { 97 | cbs[msg.request].resolve(msg.result) 98 | } 99 | delete cbs[msg.request] 100 | } 101 | }) 102 | ws.on('open', () => ws.send('sync')) 103 | return obj 104 | } 105 | 106 | export default { 107 | server, 108 | client 109 | } -------------------------------------------------------------------------------- /test/classes.test.js: -------------------------------------------------------------------------------- 1 | const classes = require('../src/classes') 2 | const { Observable } = classes 3 | 4 | test('onChange should catch mutation', done => { 5 | const o = new Observable() 6 | o.a = { b: 1 } 7 | o.onChange((keys, value, old, obj) => { 8 | expect(keys).toStrictEqual(['a', 'b']) 9 | expect(value).toBe(2) 10 | expect(old).toBe(1) 11 | expect(obj).toStrictEqual({ b: 2 }) 12 | done() 13 | }) 14 | o.a.b = 2 15 | }) 16 | 17 | test('computed should register a computed function', () => { 18 | const o = new Observable({ 19 | a: 1, 20 | b: 2, 21 | sum: 0 22 | }) 23 | o.computed(function() { this.sum = this.a + this.b }) 24 | expect(o.sum).toBe(3) 25 | o.a = 10 26 | expect(o.sum).toBe(12) 27 | }) 28 | 29 | test('dispose should unregister computed functions', () => { 30 | const o = new Observable({ 31 | a: 1, 32 | b: 2, 33 | sum: 0 34 | }) 35 | o.computed(function() { this.sum = this.a + this.b }) 36 | expect(o.sum).toBe(3) 37 | o.dispose() 38 | o.a = 10 39 | expect(o.sum).toBe(3) 40 | }) 41 | 42 | test('class inheritance', () => { 43 | const ExtendedClass = class extends Observable { 44 | constructor() { 45 | super({ 46 | a: 1, 47 | b: 1 48 | }) 49 | this.c = 1 50 | } 51 | } 52 | 53 | const instance = new ExtendedClass() 54 | instance.computed(function() { 55 | this.sum = this.a + this.b + this.c 56 | }) 57 | expect(instance.sum).toBe(3) 58 | instance.a = 2 59 | expect(instance.sum).toBe(4) 60 | instance.c = 2 61 | expect(instance.sum).toBe(5) 62 | }) 63 | -------------------------------------------------------------------------------- /test/environment.js: -------------------------------------------------------------------------------- 1 | import { TestEnvironment } from 'jest-environment-jsdom' 2 | import { TextEncoder, TextDecoder } from 'util' 3 | 4 | export default class CustomTestEnvironment extends TestEnvironment { 5 | async setup() { 6 | await super.setup() 7 | if(typeof this.global.TextEncoder === 'undefined') { 8 | this.global.TextEncoder = TextEncoder 9 | this.global.TextDecoder = TextDecoder 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /test/handlers.test.js: -------------------------------------------------------------------------------- 1 | const hyperactiv = require('../src/index').default 2 | const handlers = require('../src/handlers').default 3 | const { observe } = hyperactiv 4 | const { all, write, debug } = handlers 5 | 6 | 7 | test('write handler should proxify mutations to another object', () => { 8 | const copy = {} 9 | const obj = observe({}, { bubble: true, deep: false }) 10 | obj.__handler = write(copy) 11 | obj.a = 10 12 | expect(copy.a).toBe(10) 13 | obj.b = { c: { d: 15 } } 14 | expect(copy.b.c.d).toBe(15) 15 | obj.b.c.d = 10 16 | expect(copy.b.c.d).toBe(15) 17 | 18 | const copy2 = {} 19 | const obj2 = observe({}, { bubble: true, deep: true }) 20 | obj2.__handler = write(copy2) 21 | obj2.a = 10 22 | expect(copy2.a).toBe(10) 23 | obj2.b = { c: { d: 15 } } 24 | expect(copy2.b.c.d).toBe(15) 25 | obj2.b.c.d = 10 26 | expect(copy2.b.c.d).toBe(10) 27 | 28 | const copy3 = [] 29 | const obj3 = observe([], { bubble: true, deep: true }) 30 | obj3.__handler = write(copy3) 31 | obj3.push('test') 32 | expect(copy3[0]).toBe('test') 33 | obj3.push({ a: { b: [ { c: 1 }]}}) 34 | expect(copy3[1]).toEqual({ a: { b: [ { c: 1 }]}}) 35 | obj3[1].a.b[0].c = 2 36 | expect(copy3[1]).toEqual({ a: { b: [ { c: 2 }]}}) 37 | 38 | const copy4 = {} 39 | const obj4 = observe({ a: { b: 1 }}, { bubble: true, deep: true }) 40 | obj4.__handler = write(copy4) 41 | obj4.a.b = 2 42 | expect(copy4.a.b).toEqual(2) 43 | 44 | const copy5 = [] 45 | const obj5 = observe([[[1]]], { bubble: true, deep: true }) 46 | obj5.__handler = write(copy5) 47 | obj5[0][0][0] = 2 48 | expect(copy5[0][0][0]).toEqual(2) 49 | 50 | 51 | expect(() => write()).toThrow() 52 | 53 | // Improves coverage 54 | delete obj2.b.c 55 | }) 56 | 57 | test('debug handler should print mutations', () => { 58 | let val = '' 59 | const logger = { 60 | log: str => val += str 61 | } 62 | const obj = { a: { b: [1] }} 63 | const observed = observe(obj, { bubble: true, deep: true }) 64 | observed.__handler = all([debug(logger), debug()]) 65 | observed.a.b[0] = 2 66 | expect(val).toBe('a.b[0] = 2') 67 | }) 68 | 69 | test('all handler should run handlers sequentially', () => { 70 | let val = '' 71 | let count = 0 72 | const logger = { 73 | log: () => { 74 | val += count 75 | count++ 76 | } 77 | } 78 | const obj = { a: { b: [1] }} 79 | const observed = observe(obj, { bubble: true, deep: true }) 80 | observed.__handler = all([debug(logger), debug(logger)]) 81 | observed.a.b[0] = 2 82 | expect(val).toBe('01') 83 | 84 | // Improves coverage 85 | const observed2 = observe(obj, { bubble: true, deep: true }) 86 | observed2.__handler = all(debug(logger)) 87 | observed2.a.b[0] = 3 88 | expect(val).toBe('012') 89 | }) 90 | 91 | test('a handler that returns false should stop the bubbling', () => { 92 | let val = '' 93 | const logger = { 94 | log: str => val += str 95 | } 96 | const obj = { 97 | a: { 98 | b: [1], 99 | c: { 100 | d: 0, 101 | __handler: () => { 102 | val = '' 103 | return false 104 | } 105 | } 106 | } 107 | } 108 | const observed = observe(obj, { bubble: true, deep: true }) 109 | observed.__handler = debug(logger) 110 | observed.a.b[0] = 2 111 | expect(val).toBe('a.b[0] = 2') 112 | observed.a.c.d = 2 113 | expect(val).toBe('') 114 | observed.a.b = { inner: 1 } 115 | expect(val).toBe('a.b = {\n\t"inner": 1\n}') 116 | }) 117 | 118 | test('bubble false should prevent handler bubbling', () => { 119 | let val = '' 120 | const logger = { 121 | log: str => val += str 122 | } 123 | const obj = { 124 | a: { 125 | b: [1], 126 | c: { 127 | d: 0, 128 | __handler: () => { 129 | val = '' 130 | return false 131 | } 132 | } 133 | }, 134 | z: 0 135 | } 136 | const observed = observe(obj, { bubble: false, deep: true }) 137 | observed.__handler = debug(logger) 138 | observed.a.b[0] = 2 139 | expect(val).toBe('') 140 | observed.z = 1 141 | expect(val).toBe('z = 1') 142 | observed.z = { a: 1, b: 2 } 143 | observed.z.a = 0 144 | expect(val).toBe('z = 1z = {\n\t"a": 1,\n\t"b": 2\n}') 145 | }) -------------------------------------------------------------------------------- /test/http.test.js: -------------------------------------------------------------------------------- 1 | import wretch from 'wretch' 2 | import hyperactiv from '../src/index' 3 | import { request, normalized, resource } from '../src/http' 4 | const { observe } = hyperactiv 5 | 6 | wretch().polyfills({ 7 | fetch: require('node-fetch') 8 | }) 9 | 10 | function sleep(ms = 250) { 11 | return new Promise(resolve => { 12 | setTimeout(resolve, ms) 13 | }) 14 | } 15 | 16 | describe('React http test suite', () => { 17 | 18 | describe('request', () => { 19 | 20 | it('should fetch data', async () => { 21 | const store = observe({}) 22 | const fakeClient = wretch().middlewares([ 23 | () => () => Promise.resolve({ 24 | ok: true, 25 | text() { 26 | return Promise.resolve('text') 27 | } 28 | }) 29 | ]) 30 | 31 | const { data, future } = request('/text', { 32 | store, 33 | client: fakeClient, 34 | bodyType: 'text' 35 | }) 36 | expect(data).toBe(null) 37 | await expect(future).resolves.toBe('text') 38 | expect(store.__requests__['get@/text']).toBe('text') 39 | }) 40 | 41 | it('should throw if wretch errored', async () => { 42 | const store = observe({}) 43 | 44 | const { data, future } = request('error', { 45 | store 46 | }) 47 | expect(data).toBe(null) 48 | await expect(future).rejects.toThrow('Only absolute URLs are supported') 49 | }) 50 | 51 | it('should fetch data from the network', async () => { 52 | const store = observe({}) 53 | 54 | let counter = 0 55 | const fakeClient = wretch().middlewares([ 56 | () => () => Promise.resolve({ 57 | ok: true, 58 | async json() { 59 | await sleep() 60 | return ( 61 | counter++ === 0 ? 62 | { hello: 'hello world'} : 63 | { hello: 'bonjour le monde'} 64 | ) 65 | } 66 | }) 67 | ]) 68 | 69 | const { future } = request('/hello', { 70 | store, 71 | client: fakeClient 72 | }) 73 | 74 | await expect(future).resolves.toStrictEqual({ hello: 'hello world' }) 75 | 76 | const { future: networkFuture } = request('/hello', { 77 | store, 78 | client: fakeClient, 79 | policy: 'network-only' 80 | }) 81 | 82 | await expect(networkFuture).resolves.toStrictEqual({ hello: 'bonjour le monde' }) 83 | 84 | expect(request('/hello', { 85 | store, 86 | client: fakeClient 87 | }).data).toStrictEqual({ hello: 'bonjour le monde' }) 88 | }) 89 | }) 90 | 91 | const payload = { 92 | id: 1, 93 | title: 'My Item', 94 | post: { id: 4, date: '01-01-1970' }, 95 | users: [{ 96 | userId: 1, 97 | name: 'john' 98 | }, { 99 | userId: 2, 100 | name: 'jane', 101 | comments: [{ 102 | id: 3, 103 | subId: 1, 104 | content: 'Hello' 105 | }] 106 | }] 107 | } 108 | 109 | const normalizedPayload = { 110 | items: { 111 | 1: { 112 | id: 1, 113 | title: 'My Item', 114 | post: 4, 115 | users: [ 1, 2 ] 116 | } 117 | }, 118 | users: { 119 | 1: { userId: 1, name: 'john' }, 120 | 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } 121 | }, 122 | posts: { 123 | 4: { id: 4, date: '01-01-1970' } 124 | }, 125 | comments: { 126 | '3 - 1': { id: 3, subId: 1, content: 'Hello' } 127 | }, 128 | itemsContainer: { 129 | container_1: { 130 | items: 1 131 | } 132 | } 133 | } 134 | 135 | const normalizeOptions = { 136 | entity: 'items', 137 | schema: [ 138 | [ 'post', { mapping: 'posts' } ], 139 | [ 'users', 140 | [ 141 | ['comments', { 142 | key: comment => comment.id + ' - ' + comment.subId 143 | }] 144 | ], 145 | { 146 | key: 'userId' 147 | } 148 | ] 149 | ], 150 | from: { 151 | itemsContainer: 'container_1' 152 | } 153 | } 154 | 155 | describe('normalized', () => { 156 | 157 | it('should fetch data and normalize it', async () => { 158 | const store = observe({}) 159 | const fakeClient = wretch().middlewares([ 160 | () => () => Promise.resolve({ 161 | ok: true, 162 | json() { 163 | return Promise.resolve(payload) 164 | } 165 | }) 166 | ]) 167 | 168 | const { data, future } = normalized('/item/1', { 169 | store, 170 | client: fakeClient, 171 | normalize: normalizeOptions 172 | }) 173 | expect(data).toBe(null) 174 | await expect(future).resolves.toStrictEqual(normalizedPayload) 175 | expect(store.__requests__['get@/item/1']).toStrictEqual({ 176 | items: ['1'], 177 | users: ['1', '2'], 178 | posts: ['4'], 179 | comments: ['3 - 1'], 180 | itemsContainer: [ 181 | 'container_1' 182 | ] 183 | }) 184 | }) 185 | 186 | it('should throw if wretch errored', async () => { 187 | const store = observe({}) 188 | 189 | const { data, future } = normalized('error', { 190 | store 191 | }) 192 | expect(data).toBe(null) 193 | await expect(future).rejects.toThrow('Only absolute URLs are supported') 194 | }) 195 | 196 | it('should fetch data from the network', async () => { 197 | const store = observe({}) 198 | 199 | let counter = 0 200 | const fakeClient = wretch().middlewares([ 201 | () => () => Promise.resolve({ 202 | ok: true, 203 | async json() { 204 | await sleep() 205 | return ( 206 | counter++ >= 1 ? 207 | { 208 | ...payload, 209 | title: 'Updated Title' 210 | } : 211 | payload 212 | ) 213 | } 214 | }) 215 | ]) 216 | 217 | const { future } = normalized( 218 | '/item/1', 219 | { 220 | store, 221 | client: fakeClient, 222 | normalize: normalizeOptions 223 | } 224 | ) 225 | 226 | const data = await future 227 | expect(data.items['1'].title).toBe('My Item') 228 | 229 | const { future: networkFuture } = normalized( 230 | '/item/1', 231 | { 232 | store, 233 | client: fakeClient, 234 | normalize: normalizeOptions, 235 | policy: 'network-only' 236 | } 237 | ) 238 | 239 | const networkData = await networkFuture 240 | expect(networkData.items['1'].title).toBe('Updated Title') 241 | 242 | expect(normalized( 243 | '/item/1', 244 | { 245 | store, 246 | client: fakeClient, 247 | normalize: normalizeOptions 248 | } 249 | ).data.items['1'].title).toBe('Updated Title') 250 | }) 251 | }) 252 | 253 | describe('resource', () => { 254 | 255 | it('should fetch a single resource, normalize it and return the data', async () => { 256 | const store = observe({}) 257 | const fakeClient = wretch().middlewares([ 258 | () => () => Promise.resolve({ 259 | ok: true, 260 | json() { 261 | return Promise.resolve(payload) 262 | } 263 | }) 264 | ]) 265 | 266 | const { data, future } = resource('items', 267 | '/item/1', 268 | { 269 | id: 1, 270 | store, 271 | client: fakeClient, 272 | normalize: normalizeOptions 273 | } 274 | ) 275 | 276 | expect(data).toBe(null) 277 | await expect(future).resolves.toStrictEqual(normalizedPayload.items['1']) 278 | expect(store.__requests__['get@/item/1']).toStrictEqual({ 279 | items: ['1'], 280 | users: ['1', '2'], 281 | posts: ['4'], 282 | comments: ['3 - 1'], 283 | itemsContainer: [ 284 | 'container_1' 285 | ] 286 | }) 287 | }) 288 | 289 | it('should fetch multiple resources, normalize them and return the data', async () => { 290 | const store = observe({}) 291 | const fakeClient = wretch().middlewares([ 292 | () => () => Promise.resolve({ 293 | ok: true, 294 | json() { 295 | return Promise.resolve([payload]) 296 | } 297 | }) 298 | ]) 299 | 300 | const { data, future } = resource( 301 | 'items', 302 | '/items', 303 | { 304 | store, 305 | client: fakeClient, 306 | normalize: normalizeOptions 307 | } 308 | ) 309 | 310 | expect(data).toBe(null) 311 | await expect(future).resolves.toStrictEqual([normalizedPayload.items['1']]) 312 | expect(store.__requests__['get@/items']).toStrictEqual({ 313 | items: ['1'], 314 | users: ['1', '2'], 315 | posts: ['4'], 316 | comments: ['3 - 1'], 317 | itemsContainer: [ 318 | 'container_1' 319 | ] 320 | }) 321 | }) 322 | 323 | it('should retrieve data from the cache by id', async () => { 324 | const store = observe({ 325 | item: { 326 | 1: { 327 | id: 1, 328 | title: 'Title' 329 | } 330 | }, 331 | __requests__: { 332 | testKey: { 333 | item: [1] 334 | } 335 | } 336 | }) 337 | 338 | const fakeClient = wretch().middlewares([ 339 | () => () => Promise.resolve({ 340 | ok: true, 341 | async json() { 342 | await sleep() 343 | return { 344 | id: 1, 345 | title: 'Updated title' 346 | } 347 | } 348 | }) 349 | ]) 350 | 351 | const { data, future } = resource( 352 | 'item', 353 | '/item/1', 354 | { 355 | id: 1, 356 | store, 357 | client: fakeClient, 358 | serialize: () => 'testKey', 359 | policy: 'cache-and-network' 360 | } 361 | ) 362 | 363 | expect(data).toStrictEqual({ 364 | id: 1, 365 | title: 'Title' 366 | }) 367 | 368 | await expect(future).resolves.toStrictEqual({ 369 | id: 1, 370 | title: 'Updated title' 371 | }) 372 | }) 373 | 374 | it('should refetch data properly', async () => { 375 | const store = observe({ 376 | item: { 377 | 1: { 378 | id: 1, 379 | title: 'Title' 380 | } 381 | }, 382 | __requests__: { 383 | testKey: { 384 | item: [1] 385 | } 386 | } 387 | }) 388 | 389 | const fakeClient = wretch().middlewares([ 390 | () => () => Promise.resolve({ 391 | ok: true, 392 | async json() { 393 | await sleep() 394 | return { 395 | id: 1, 396 | title: 'Updated title' 397 | } 398 | } 399 | }) 400 | ]) 401 | 402 | const { data, refetch } = resource('item', 403 | '/item/1', 404 | { 405 | id: 1, 406 | store, 407 | client: fakeClient, 408 | serialize: () => 'testKey', 409 | policy: 'cache-first' 410 | } 411 | ) 412 | 413 | expect(data).toStrictEqual({ 414 | id: 1, 415 | title: 'Title' 416 | }) 417 | 418 | await expect(refetch()).resolves.toStrictEqual({ 419 | id: 1, 420 | title: 'Updated title' 421 | }) 422 | }) 423 | }) 424 | }) -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const hyperactiv = require('../src/index').default 2 | const { computed, observe, dispose } = hyperactiv 3 | 4 | const delay = time => new Promise(resolve => setTimeout(resolve, time)) 5 | 6 | test('simple computation', () => { 7 | const obj = observe({ 8 | a: 1, b: 2 9 | }) 10 | 11 | let result = 0 12 | 13 | const sum = computed(() => { 14 | result = obj.a + obj.b 15 | }, { autoRun: false }) 16 | sum() 17 | 18 | expect(result).toBe(3) 19 | obj.a = 2 20 | expect(result).toBe(4) 21 | obj.b = 3 22 | expect(result).toBe(5) 23 | }) 24 | 25 | test('auto-run computed function', () => { 26 | const obj = observe({ 27 | a: 1, b: 2 28 | }) 29 | 30 | let result = 0 31 | 32 | computed(() => { 33 | result = obj.a + obj.b 34 | }) 35 | 36 | expect(result).toBe(3) 37 | }) 38 | 39 | test('multiple getters', () => { 40 | const obj = observe({ 41 | a: 1, 42 | b: 2, 43 | sum: 0 44 | }, { props: [ 'a', 'b' ]}) 45 | 46 | computed(() => { 47 | obj.sum += obj.a 48 | obj.sum += obj.b 49 | obj.sum += obj.a + obj.b 50 | }, { autoRun: true }) 51 | 52 | // 1 + 2 + 3 53 | expect(obj.sum).toBe(6) 54 | 55 | obj.a = 2 56 | 57 | // 6 + 2 + 2 + 4 58 | expect(obj.sum).toBe(14) 59 | }) 60 | 61 | test('nested functions', () => { 62 | const obj = observe({ 63 | a: 1, 64 | b: 2, 65 | c: 3, 66 | d: 4 67 | }) 68 | 69 | let result 70 | 71 | const aPlusB = () => obj.a + obj.b 72 | const cPlusD = () => obj.c + obj.d 73 | 74 | computed(() => { 75 | result = aPlusB() + cPlusD() 76 | }) 77 | 78 | expect(result).toBe(10) 79 | obj.a = 2 80 | expect(result).toBe(11) 81 | obj.d = 5 82 | expect(result).toBe(12) 83 | }) 84 | 85 | test('multiple observed objects', () => { 86 | const obj1 = observe({ a: 1 }) 87 | const obj2 = observe({ a: 2 }) 88 | const obj3 = observe({ a: 3 }) 89 | 90 | let result = 0 91 | 92 | computed(() => { 93 | result = obj1.a + obj2.a + obj3.a 94 | }) 95 | 96 | expect(result).toBe(6) 97 | obj1.a = 0 98 | expect(result).toBe(5) 99 | obj2.a = 0 100 | expect(result).toBe(3) 101 | obj3.a = 0 102 | expect(result).toBe(0) 103 | }) 104 | 105 | test('circular computed function', () => { 106 | const obj = observe({ a: 1, b: 1 }) 107 | computed(() => { 108 | obj.a += obj.b 109 | }) 110 | expect(obj.a).toBe(2) 111 | obj.b = 2 112 | expect(obj.a).toBe(4) 113 | obj.a = 3 114 | expect(obj.a).toBe(5) 115 | }) 116 | 117 | test('array methods', () => { 118 | const arr = observe([{ val: 1 }, { val: 2 }, { val: 3 }]) 119 | let sum = 0 120 | computed(() => { sum = arr.reduce((acc, { val }) => acc + val, 0) }) 121 | expect(sum).toBe(6) 122 | arr.push({ val: 4 }) 123 | expect(sum).toBe(10) 124 | arr.pop() 125 | expect(sum).toBe(6) 126 | arr.unshift({ val: 5 }, { val: 4 }) 127 | expect(sum).toBe(15) 128 | arr.shift() 129 | expect(sum).toBe(10) 130 | arr.splice(1, 3) 131 | expect(sum).toBe(4) 132 | }) 133 | 134 | test('dispose computed functions', () => { 135 | const obj = observe({ a: 0 }) 136 | let result = 0 137 | let result2 = 0 138 | 139 | const minusOne = computed(() => { 140 | result2 = obj.a - 1 141 | }) 142 | computed(() => { 143 | result = obj.a + 1 144 | }) 145 | 146 | obj.a = 1 147 | expect(result).toBe(2) 148 | expect(result2).toBe(0) 149 | dispose(minusOne) 150 | obj.a = 10 151 | expect(result).toBe(11) 152 | expect(result2).toBe(0) 153 | }) 154 | 155 | test('does not observe the original object', () => { 156 | const obj = { a: 1 } 157 | const obs = observe(obj) 158 | let plusOne = 0 159 | computed(() => { 160 | plusOne = obs.a + 1 161 | }) 162 | expect(plusOne).toBe(2) 163 | obj.a = 2 164 | expect(plusOne).toBe(2) 165 | obs.a = 3 166 | expect(plusOne).toBe(4) 167 | }) 168 | 169 | test('chain of computations', () => { 170 | const obj = observe({ 171 | a: 0, 172 | b: 0, 173 | c: 0, 174 | d: 0 175 | }) 176 | 177 | computed(() => { obj.b = obj.a * 2 }) 178 | computed(() => { obj.c = obj.b * 2 }) 179 | computed(() => { obj.d = obj.c * 2 }) 180 | 181 | expect(obj.d).toBe(0) 182 | obj.a = 5 183 | expect(obj.d).toBe(40) 184 | }) 185 | 186 | test('asynchronous computation', async () => { 187 | const obj = observe({ a: 0, b: 0 }) 188 | 189 | const addOne = () => { 190 | obj.b = obj.a + 1 191 | } 192 | const delayedAddOne = computed( 193 | ({ computeAsync }) => delay(200).then(() => computeAsync(addOne)), 194 | { autoRun: false } 195 | ) 196 | await delayedAddOne() 197 | 198 | obj.a = 2 199 | expect(obj.b).toBe(1) 200 | 201 | await delay(250).then(() => { 202 | expect(obj.b).toBe(3) 203 | }) 204 | }) 205 | 206 | test('concurrent asynchronous computations', async () => { 207 | const obj = observe({ a: 0, b: 0, c: 0 }) 208 | let result = 0 209 | 210 | const plus = prop => computed(async ({ computeAsync }) => { 211 | await delay(200) 212 | computeAsync(() => result += obj[prop]) 213 | }, { autoRun: false }) 214 | const plusA = plus('a') 215 | const plusB = plus('b') 216 | const plusC = plus('c') 217 | 218 | await Promise.all([ plusA(), plusB(), plusC() ]) 219 | 220 | expect(result).toBe(0) 221 | 222 | obj.a = 1 223 | obj.b = 2 224 | obj.c = 3 225 | 226 | await delay(250).then(() => { 227 | expect(result).toBe(6) 228 | }) 229 | }) 230 | 231 | test('observe arrays', () => { 232 | const arr = observe([1, 2, 3]) 233 | let sum = 0 234 | computed(() => sum = arr.reduce((acc, curr) => acc + curr)) 235 | expect(sum).toBe(6) 236 | 237 | arr[0] = 2 238 | expect(sum).toBe(7) 239 | }) 240 | 241 | test('usage with "this"', () => { 242 | const obj = observe({ 243 | a: 1, 244 | b: 2, 245 | doSum: function() { 246 | this.sum = this.a + this.b 247 | } 248 | }) 249 | 250 | obj.doSum = computed(obj.doSum.bind(obj)) 251 | expect(obj.sum).toBe(3) 252 | obj.a = 2 253 | expect(obj.sum).toBe(4) 254 | }) 255 | 256 | test('"class" syntax', () => { 257 | class MyClass { 258 | constructor() { 259 | this.a = 1 260 | this.b = 2 261 | 262 | const _this = observe(this) 263 | this.doSum = computed(this.doSum.bind(_this)) 264 | return _this 265 | } 266 | 267 | doSum() { 268 | this.sum = this.a + this.b 269 | } 270 | } 271 | 272 | const obj = new MyClass() 273 | expect(obj.sum).toBe(3) 274 | obj.a = 2 275 | expect(obj.sum).toBe(4) 276 | }) 277 | 278 | test('not observe ignored properties (props & ignore: array)', () => { 279 | const object = { 280 | a: 0, 281 | b: 0, 282 | sum: 0 283 | } 284 | const observeA = observe(object, { props: ['a'] }) 285 | const observeB = observe(object, { ignore: ['a', 'sum'] }) 286 | 287 | computed(function() { 288 | observeA.sum = observeA.a + observeB.b 289 | }) 290 | 291 | observeA.a = 2 292 | expect(object.sum).toBe(2) 293 | observeA.b = 1 294 | observeB.a = 1 295 | expect(object.sum).toBe(2) 296 | observeB.b = 2 297 | expect(object.sum).toBe(3) 298 | }) 299 | 300 | test('not observe ignored properties (props & ignore: function)', () => { 301 | const object = { 302 | a: 0, 303 | b: 0, 304 | sum: 0 305 | } 306 | const observeA = observe(object, { props: key => key === 'a' }) 307 | const observeB = observe(object, { ignore: key => ['a', 'sum'].includes(key) }) 308 | 309 | computed(function() { 310 | observeA.sum = observeA.a + observeB.b 311 | }) 312 | 313 | observeA.a = 2 314 | expect(object.sum).toBe(2) 315 | observeA.b = 1 316 | observeB.a = 1 317 | expect(object.sum).toBe(2) 318 | observeB.b = 2 319 | expect(object.sum).toBe(3) 320 | }) 321 | 322 | test('batch computations', async () => { 323 | expect.assertions(6) 324 | 325 | const array = observe([0, 0, 0], { batch: true }) 326 | let sum = 0 327 | 328 | computed(() => { 329 | expect(true).toBe(true) 330 | sum = array.reduce((acc, curr) => acc + curr) 331 | }) 332 | 333 | expect(sum).toBe(0) 334 | 335 | array[0] = 0 336 | array[0] = 1 337 | array[1] = 2 338 | array[2] = 3 339 | 340 | await delay(100) 341 | expect(sum).toBe(6) 342 | 343 | array[0] = 6 344 | array[0] = 7 345 | array[1] = 8 346 | array[2] = 10 347 | 348 | await delay(100) 349 | expect(sum).toBe(25) 350 | }) 351 | 352 | test('batch computations with custom debounce', async () => { 353 | expect.assertions(8) 354 | 355 | const array = observe([0, 0, 0], { batch: 1000 }) 356 | let sum = 0 357 | 358 | computed(() => { 359 | expect(true).toBe(true) 360 | sum = array.reduce((acc, curr) => acc + curr) 361 | }) 362 | 363 | expect(sum).toBe(0) 364 | 365 | array[0] = 0 366 | array[1] = 2 367 | array[2] = 3 368 | 369 | await delay(500) 370 | expect(sum).toBe(0) 371 | array[0] = 1 372 | await delay(500) 373 | expect(sum).toBe(6) 374 | 375 | array[0] = 6 376 | array[1] = 8 377 | array[2] = 10 378 | 379 | await delay(500) 380 | array[0] = 7 381 | expect(sum).toBe(6) 382 | await delay(500) 383 | expect(sum).toBe(25) 384 | }) 385 | 386 | test('run a callback instead of the computed function', () => { 387 | const obj = observe({ 388 | a: 1, b: 0 389 | }) 390 | 391 | const incrementB = () => { 392 | obj.b++ 393 | } 394 | computed(() => { 395 | expect(obj.a).toBe(1) 396 | }, { callback: incrementB }) 397 | 398 | expect(obj.b).toBe(0) 399 | obj.a = 2 400 | expect(obj.a).toBe(2) 401 | expect(obj.b).toBe(1) 402 | }) 403 | 404 | test('deep observe nested objects and new properties', () => { 405 | const o = { a: { b: 1 }, tab: [{ z: 1 }]} 406 | Object.setPrototypeOf(o, { _unused: true }) 407 | const obj = observe(o) 408 | 409 | obj.c = { d: { e: 2 } } 410 | 411 | computed(() => { 412 | obj.sum = (obj.a && obj.a.b) + obj.c.d.e + obj.tab[0].z 413 | }) 414 | expect(obj.sum).toBe(4) 415 | obj.a.b = 2 416 | expect(obj.sum).toBe(5) 417 | obj.c.d.e = 3 418 | expect(obj.sum).toBe(6) 419 | obj.tab[0].z = 2 420 | expect(obj.sum).toBe(7) 421 | 422 | // null check 423 | obj.a = null 424 | expect(obj.sum).toBe(5) 425 | }) 426 | 427 | test('shallow observe nested objects when deep is false', () => { 428 | const o = { a: { b: 1 }, c: 1, tab: [{ z: 1 }]} 429 | const obj = observe(o, { deep: false }) 430 | obj.d = { e: { f: 2 } } 431 | computed(() => { 432 | obj.sum = obj.a.b + obj.c + obj.d.e.f + obj.tab[0].z 433 | }) 434 | expect(obj.sum).toBe(5) 435 | obj.a.b = 2 436 | expect(obj.sum).toBe(5) 437 | obj.c = 2 438 | expect(obj.sum).toBe(7) 439 | }) 440 | 441 | test('bind methods to the observed object', () => { 442 | const obj = observe({ 443 | a: 1, 444 | b: 1, 445 | c: new Date(), 446 | doSum: function() { 447 | this.sum = this.a + this.b 448 | } 449 | }, { bind: true }) 450 | 451 | obj.doSum = computed(obj.doSum) 452 | expect(obj.sum).toBe(2) 453 | obj.a = 2 454 | expect(obj.sum).toBe(3) 455 | }) 456 | 457 | test('bind methods to the observed class', () => { 458 | class TestClass { 459 | constructor() { 460 | this.a = 1 461 | this.b = 2 462 | } 463 | method() { 464 | this.sum = this.a + this.b 465 | } 466 | } 467 | const observer = observe(new TestClass(), { bind: true }) 468 | observer.method = computed(observer.method) 469 | expect(observer.sum).toBe(3) 470 | observer.a = 2 471 | expect(observer.sum).toBe(4) 472 | 473 | }) 474 | 475 | test('bind computed functions using the bind option', () => { 476 | const obj = observe({ 477 | a: 1, 478 | b: 2, 479 | doSum: function() { 480 | this.sum = this.a + this.b 481 | } 482 | }) 483 | 484 | obj.doSum = computed(obj.doSum, { bind: obj }) 485 | expect(obj.sum).toBe(3) 486 | obj.a = 2 487 | expect(obj.sum).toBe(4) 488 | }) 489 | 490 | test('track unused function dependencies', () => { 491 | const observed = observe({ 492 | condition: true, 493 | a: 0, 494 | b: 0 495 | }) 496 | 497 | const object = { 498 | counter: 0 499 | } 500 | 501 | computed(() => { 502 | if(observed.condition) { 503 | observed.a++ 504 | } else { 505 | observed.b++ 506 | } 507 | object.counter++ 508 | }) 509 | 510 | expect(object.counter).toBe(1) 511 | observed.a++ 512 | expect(object.counter).toBe(2) 513 | observed.condition = false 514 | expect(object.counter).toBe(3) 515 | observed.a++ 516 | expect(object.counter).toBe(3) 517 | observed.b++ 518 | expect(object.counter).toBe(4) 519 | }) 520 | 521 | test('not track unused function dependencies if the disableTracking flag is true', () => { 522 | const observed = observe({ 523 | condition: true, 524 | a: 0, 525 | b: 0 526 | }) 527 | 528 | const object = { 529 | counter: 0 530 | } 531 | 532 | computed(() => { 533 | if(observed.condition) { 534 | observed.a++ 535 | } else { 536 | observed.b++ 537 | } 538 | object.counter++ 539 | }, { disableTracking: true }) 540 | 541 | expect(object.counter).toBe(1) 542 | observed.a++ 543 | expect(object.counter).toBe(2) 544 | observed.condition = false 545 | expect(object.counter).toBe(3) 546 | observed.a++ 547 | expect(object.counter).toBe(4) 548 | observed.b++ 549 | expect(object.counter).toBe(5) 550 | }) -------------------------------------------------------------------------------- /test/react/__snapshots__/context.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`React context test suite SSR Provider and preloadData should resolve promises and render markup 1`] = ` 4 |
5 | hello world 6 | 7 | bonjour le monde 8 | 9 | 1 10 |
11 | `; 12 | 13 | exports[`React context test suite preloadData should resolve promises based on its depth option 1`] = ` 14 |
15 | hello world 16 | 17 | 18 |
19 | `; 20 | 21 | exports[`React context test suite preloadData should skip promises if the ssr option if false 1`] = ` 22 |
23 | 24 | 25 |
26 | `; 27 | -------------------------------------------------------------------------------- /test/react/components.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment ./test/environment 3 | */ 4 | 5 | import React from 'react' 6 | import { 7 | render, 8 | fireEvent, 9 | cleanup, 10 | waitFor 11 | } from '@testing-library/react' 12 | import '@testing-library/jest-dom/extend-expect' 13 | 14 | import { 15 | Watch, 16 | watch, 17 | store as createStore, 18 | HyperactivProvider 19 | } from '../../src/react' 20 | 21 | afterEach(cleanup) 22 | 23 | describe('React components test suite', () => { 24 | 25 | const commonJsx = store => 26 |
27 | store.firstName = e.target.value } 30 | /> 31 | store.lastName = e.target.value } 34 | /> 35 |
36 | Hello, { store.firstName } { store.lastName } ! 37 |
38 |
39 | 40 | async function testStoreUpdate(Component, store) { 41 | const { getByTestId } = render() 42 | 43 | expect(getByTestId('firstname')).toHaveValue(store.firstName) 44 | expect(getByTestId('lastname')).toHaveValue(store.lastName) 45 | expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) 46 | 47 | fireEvent.change(getByTestId('firstname'), { 48 | target: { 49 | value: 'John' 50 | } 51 | }) 52 | 53 | fireEvent.change(getByTestId('lastname'), { 54 | target: { 55 | value: 'Doe' 56 | } 57 | }) 58 | 59 | expect(store).toEqual({ firstName: 'John', lastName: 'Doe' }) 60 | 61 | await waitFor(() => { 62 | expect(getByTestId('firstname')).toHaveValue(store.firstName) 63 | expect(getByTestId('lastname')).toHaveValue(store.lastName) 64 | expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) 65 | }) 66 | } 67 | 68 | describe('watch()', () => { 69 | 70 | it('should observe a class component', () => { 71 | const store = createStore({ 72 | firstName: 'Igor', 73 | lastName: 'Gonzola' 74 | }) 75 | const ClassComponent = watch(class extends React.Component { 76 | render() { 77 | return commonJsx(store) 78 | } 79 | }) 80 | 81 | return testStoreUpdate(ClassComponent, store) 82 | }) 83 | 84 | it('should observe a functional component', () => { 85 | const store = createStore({ 86 | firstName: 'Igor', 87 | lastName: 'Gonzola' 88 | }) 89 | const FunctionalComponent = watch(() => 90 | commonJsx(store) 91 | ) 92 | 93 | return testStoreUpdate(FunctionalComponent, store) 94 | }) 95 | 96 | test('wrapping a functional component should inject the `store` prop', () => { 97 | const store = createStore({ 98 | hello: 'World' 99 | }) 100 | const Wrapper = watch(props =>
{props.store && props.store.hello}
) 101 | const { getByTestId } = render( 102 | 103 | ) 104 | expect(getByTestId('hello-div')).toContainHTML('') 105 | const { getByText } = render( 106 | 107 | 108 | 109 | ) 110 | expect(getByText('World')).toBeTruthy() 111 | }) 112 | 113 | test('wrapping a functional component should not inject the `store` prop if a prop with this name already exists', () => { 114 | const store = createStore({ 115 | hello: 'World' 116 | }) 117 | const Wrapper = watch(props =>
{props.store && props.store.hello}
) 118 | const { getByTestId } = render( 119 | 120 | 121 | 122 | ) 123 | expect(getByTestId('hello-div')).toHaveTextContent('bonjour') 124 | }) 125 | 126 | test('wrapping a class component should gracefully unmount if the child component has a componentWillUnmount method', () => { 127 | let unmounted = false 128 | const Wrapper = watch(class extends React.Component { 129 | componentWillUnmount() { 130 | unmounted = true 131 | } 132 | render() { 133 | return
Hello
134 | } 135 | }) 136 | const { getByText, unmount } = render() 137 | expect(unmounted).toBe(false) 138 | expect(getByText('Hello')).toBeTruthy() 139 | unmount() 140 | expect(unmounted).toBe(true) 141 | }) 142 | }) 143 | 144 | describe('', () => { 145 | it('should observe its render function', () => { 146 | const store = createStore({ 147 | firstName: 'Igor', 148 | lastName: 'Gonzola' 149 | }) 150 | const ComponentWithWatch = () => 151 | commonJsx(store)} /> 152 | 153 | return testStoreUpdate(ComponentWithWatch, store) 154 | }) 155 | it('should not render anything if no render prop is passed', () => { 156 | const { container } = render() 157 | expect(container).toContainHTML('') 158 | }) 159 | }) 160 | 161 | }) 162 | -------------------------------------------------------------------------------- /test/react/context.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment ./test/environment 3 | */ 4 | 5 | import React from 'react' 6 | import wretch from 'wretch' 7 | import { normaliz } from 'normaliz' 8 | import { 9 | render, 10 | cleanup 11 | } from '@testing-library/react' 12 | import '@testing-library/jest-dom/extend-expect' 13 | import TestRenderer from 'react-test-renderer' 14 | 15 | import { 16 | store as createStore, 17 | HyperactivProvider, 18 | HyperactivContext, 19 | preloadData, 20 | useRequest, 21 | useNormalizedRequest, 22 | useStore, 23 | useClient, 24 | setHooksDependencies 25 | } from '../../src/react' 26 | 27 | afterEach(cleanup) 28 | setHooksDependencies({ wretch, normaliz }) 29 | wretch().polyfills({ 30 | fetch: require('node-fetch') 31 | }) 32 | 33 | const fakeClient = wretch().middlewares([ 34 | () => url => 35 | Promise.resolve({ 36 | ok: true, 37 | json() { 38 | switch (url) { 39 | case '/error': 40 | return Promise.reject('rejected') 41 | case '/hello': 42 | return Promise.resolve({ hello: 'hello world'}) 43 | case '/bonjour': 44 | return Promise.resolve({ bonjour: 'bonjour le monde'}) 45 | case '/entity': 46 | return Promise.resolve({ id: 1 }) 47 | } 48 | } 49 | }) 50 | ]) 51 | 52 | const SSRComponent = ({ error, errorNormalized, noSSR }) => { 53 | const { data, loading } = useRequest( 54 | error ? '/error' : '/hello', 55 | { 56 | serialize: () => 'test', 57 | ssr: !noSSR 58 | } 59 | ) 60 | const { data: data2 } = useRequest( 61 | '/bonjour', 62 | { 63 | skip: () => loading, 64 | serialize: () => 'test2', 65 | ssr: !noSSR 66 | } 67 | ) 68 | const { data: data3 } = useNormalizedRequest( 69 | errorNormalized ? '/error' : '/entity', 70 | { 71 | skip: () => loading, 72 | serialize: () => 'test3', 73 | normalize: { 74 | schema: [], 75 | entity: 'entity' 76 | }, 77 | ssr: !noSSR 78 | } 79 | ) 80 | return
{data && data.hello} {data2 && data2.bonjour} {data3 && data3.entity['1'].id }
81 | } 82 | 83 | describe('React context test suite', () => { 84 | test('Context provider should inject a client and a store', () => { 85 | const client = 'client' 86 | const store = 'store' 87 | const { getByText } = render( 88 | 89 | 90 | { value =>
{value.store} {value.client}
} 91 |
92 |
93 | ) 94 | expect(getByText('store client')).toBeTruthy() 95 | }) 96 | 97 | test('Context provider should not inject anything by default', () => { 98 | const { getByText } = render( 99 | 100 | 101 | { value =>
{value && value.store || 'nothing'} {value && value.client || 'here'}
} 102 |
103 |
104 | ) 105 | expect(getByText('nothing here')).toBeTruthy() 106 | }) 107 | 108 | test('useStore and useClient should read the store and client from the context', () => { 109 | const client = 'client' 110 | const store = 'store' 111 | const Component = () => { 112 | const store = useStore() 113 | const client = useClient() 114 | 115 | return
{store} {client}
116 | } 117 | const { getByText } = render( 118 | 119 | 120 | 121 | ) 122 | expect(getByText('store client')).toBeTruthy() 123 | }) 124 | 125 | test('SSR Provider and preloadData should resolve promises and render markup', async () => { 126 | const store = createStore({}) 127 | const jsx = 128 | 129 | 130 | 131 | 132 | await TestRenderer.act(async () => { 133 | await preloadData(jsx) 134 | }) 135 | 136 | expect(store).toEqual({ 137 | entity: { 138 | 1: { 139 | id: 1 140 | } 141 | }, 142 | __requests__: { 143 | test: { 144 | hello: 'hello world' 145 | }, 146 | test2: { 147 | bonjour: 'bonjour le monde' 148 | }, 149 | test3: { 150 | entity: [ '1' ] 151 | } 152 | } 153 | }) 154 | 155 | expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() 156 | }) 157 | 158 | test('preloadData should resolve promises based on its depth option', async () => { 159 | const store = createStore({}) 160 | const jsx = 161 | 162 | 163 | 164 | 165 | await TestRenderer.act(async () => { 166 | await preloadData(jsx, { depth: 1 }) 167 | }) 168 | expect(store).toEqual({ 169 | __requests__: { 170 | test: { 171 | hello: 'hello world' 172 | } 173 | } 174 | }) 175 | expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() 176 | }) 177 | 178 | test('preloadData should skip promises if the ssr option if false', async () => { 179 | const store = createStore({}) 180 | const jsx = 181 | 182 | 183 | 184 | 185 | await TestRenderer.act(async () => { 186 | await preloadData(jsx) 187 | }) 188 | expect(store).toEqual({ 189 | __requests__: {} 190 | }) 191 | expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() 192 | }) 193 | 194 | test('preloadData should propagate errors', async () => { 195 | const store = createStore({}) 196 | const jsx = 197 | 198 | 199 | 200 | const jsx2 = 201 | 202 | 203 | 204 | 205 | try { 206 | await expect(preloadData(jsx)).rejects.toThrowError('rejected') 207 | } catch(error) { 208 | // silent 209 | } 210 | try { 211 | await expect(preloadData(jsx2)).rejects.toThrowError('rejected') 212 | } catch(error) { 213 | // silent 214 | } 215 | expect(store).toEqual({ 216 | __requests__: { 217 | test: { 218 | hello: 'hello world' 219 | }, 220 | test2: { 221 | bonjour: 'bonjour le monde' 222 | } 223 | } 224 | }) 225 | }) 226 | }) -------------------------------------------------------------------------------- /test/react/hooks.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment ./test/environment 3 | */ 4 | 5 | import React from 'react' 6 | import wretch from 'wretch' 7 | import { normaliz } from 'normaliz' 8 | import { 9 | render, 10 | waitFor, 11 | cleanup, 12 | fireEvent 13 | } from '@testing-library/react' 14 | import '@testing-library/jest-dom/extend-expect' 15 | import { sleep } from './utils' 16 | 17 | import { 18 | watch, 19 | store as createStore, 20 | useRequest, 21 | useNormalizedRequest, 22 | useResource, 23 | setHooksDependencies 24 | } from '../../src/react' 25 | import { 26 | normalizedOperations 27 | } from '../../src/http/tools' 28 | 29 | afterEach(cleanup) 30 | setHooksDependencies({ wretch, normaliz }) 31 | wretch().polyfills({ 32 | fetch: require('node-fetch') 33 | }) 34 | 35 | describe('React hooks test suite', () => { 36 | describe('useRequest', () => { 37 | it('should fetch data', async () => { 38 | const store = {} 39 | const fakeClient = wretch().middlewares([ 40 | () => () => Promise.resolve({ 41 | ok: true, 42 | text() { 43 | return Promise.resolve('text') 44 | } 45 | }) 46 | ]) 47 | const Component = () => { 48 | const { loading, data } = useRequest( 49 | '/text', 50 | { 51 | store, 52 | client: fakeClient, 53 | bodyType: 'text' 54 | } 55 | ) 56 | 57 | return ( 58 | loading ? 59 |
loading
: 60 |
{ data }
61 | ) 62 | } 63 | 64 | const { getByText } = render() 65 | expect(getByText('loading')).toBeTruthy() 66 | await waitFor(() => { 67 | expect(getByText('text')).toBeTruthy() 68 | }) 69 | }) 70 | 71 | it('should throw if wretch errored', async () => { 72 | const store = {} 73 | const Component = () => { 74 | const { loading, error } = useRequest( 75 | 'error', 76 | { 77 | store 78 | } 79 | ) 80 | 81 | return ( 82 | loading ? 83 |
loading
: 84 | error ? 85 |
{ error.message }
: 86 | null 87 | ) 88 | } 89 | const { getByText } = render() 90 | expect(getByText('loading')).toBeTruthy() 91 | await waitFor(() => { 92 | expect(getByText('Only absolute URLs are supported')).toBeTruthy() 93 | }) 94 | }) 95 | 96 | it('should fetch data from the network', async () => { 97 | const store = createStore({}) 98 | 99 | let counter = 0 100 | const fakeClient = wretch().middlewares([ 101 | () => () => Promise.resolve({ 102 | ok: true, 103 | async json() { 104 | await sleep() 105 | return ( 106 | counter++ === 0 ? 107 | { hello: 'hello world'} : 108 | { hello: 'bonjour le monde'} 109 | ) 110 | } 111 | }) 112 | ]) 113 | 114 | const Component = watch(() => { 115 | const { loading, data } = useRequest( 116 | '/hello', 117 | { 118 | store, 119 | client: fakeClient 120 | } 121 | ) 122 | 123 | const { loading: networkLoading, data: networkData } = useRequest( 124 | '/hello', 125 | { 126 | store, 127 | client: fakeClient, 128 | skip: () => loading, 129 | policy: 'network-only' 130 | } 131 | ) 132 | 133 | if(loading && !networkLoading) 134 | return
loading…
135 | 136 | if(!loading && networkLoading && data && !networkData) 137 | return
{ data.hello }
138 | 139 | if(data && networkData) 140 | return
{ data.hello + ' ' + networkData.hello }
141 | 142 | return null 143 | }) 144 | 145 | const { getByText } = render() 146 | expect(getByText('loading…')).toBeTruthy() 147 | await waitFor(() => { 148 | expect(getByText('hello world')).toBeTruthy() 149 | }) 150 | await waitFor(() => { 151 | expect(getByText('bonjour le monde bonjour le monde')).toBeTruthy() 152 | }) 153 | }) 154 | }) 155 | 156 | const payload = { 157 | id: 1, 158 | title: 'My Item', 159 | post: { id: 4, date: '01-01-1970' }, 160 | users: [{ 161 | userId: 1, 162 | name: 'john' 163 | }, { 164 | userId: 2, 165 | name: 'jane', 166 | comments: [{ 167 | id: 3, 168 | subId: 1, 169 | content: 'Hello' 170 | }] 171 | }] 172 | } 173 | 174 | const normalizedPayload = { 175 | items: { 176 | 1: { 177 | id: 1, 178 | title: 'My Item', 179 | post: 4, 180 | users: [ 1, 2 ] 181 | } 182 | }, 183 | users: { 184 | 1: { userId: 1, name: 'john' }, 185 | 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } 186 | }, 187 | posts: { 188 | 4: { id: 4, date: '01-01-1970' } 189 | }, 190 | comments: { 191 | '3 - 1': { id: 3, subId: 1, content: 'Hello' } 192 | }, 193 | itemsContainer: { 194 | container_1: { 195 | items: 1 196 | } 197 | } 198 | } 199 | 200 | const normalizeOptions = { 201 | entity: 'items', 202 | schema: [ 203 | [ 'post', { mapping: 'posts' } ], 204 | [ 'users', 205 | [ 206 | ['comments', { 207 | key: comment => comment.id + ' - ' + comment.subId 208 | }] 209 | ], 210 | { 211 | key: 'userId' 212 | } 213 | ] 214 | ], 215 | from: { 216 | itemsContainer: 'container_1' 217 | } 218 | } 219 | 220 | describe('useNormalizedRequest', () => { 221 | 222 | it('should fetch data and normalize it', async () => { 223 | const store = {} 224 | const fakeClient = wretch().middlewares([ 225 | () => () => Promise.resolve({ 226 | ok: true, 227 | json() { 228 | return Promise.resolve(payload) 229 | } 230 | }) 231 | ]) 232 | const Component = () => { 233 | const { loading, data } = useNormalizedRequest( 234 | '/item/1', 235 | { 236 | store, 237 | client: fakeClient, 238 | normalize: normalizeOptions 239 | } 240 | ) 241 | 242 | return ( 243 | loading ? 244 |
loading
: 245 |
{ JSON.stringify(data) }
246 | ) 247 | } 248 | 249 | const { getByText, getByTestId } = render() 250 | expect(getByText('loading')).toBeTruthy() 251 | await waitFor(() => { 252 | expect(getByTestId('stringified-data')).toBeTruthy() 253 | }) 254 | expect(JSON.parse(getByTestId('stringified-data').textContent)).toEqual(normalizedPayload) 255 | }) 256 | 257 | it('should throw if wretch errored', async () => { 258 | const store = {} 259 | const Component = () => { 260 | const { loading, error } = useNormalizedRequest( 261 | 'error', 262 | { store } 263 | ) 264 | 265 | return ( 266 | loading ? 267 |
loading
: 268 | error ? 269 |
{ error.message }
: 270 | null 271 | ) 272 | } 273 | const { getByText } = render() 274 | expect(getByText('loading')).toBeTruthy() 275 | await waitFor(() => { 276 | expect(getByText('Only absolute URLs are supported')).toBeTruthy() 277 | }) 278 | }) 279 | 280 | it('should fetch data from the network', async () => { 281 | const store = createStore({}) 282 | 283 | let counter = 0 284 | const fakeClient = wretch().middlewares([ 285 | () => () => Promise.resolve({ 286 | ok: true, 287 | async json() { 288 | await sleep() 289 | return ( 290 | counter++ >= 1 ? 291 | { 292 | ...payload, 293 | title: 'Updated Title' 294 | } : 295 | payload 296 | ) 297 | } 298 | }) 299 | ]) 300 | 301 | const Component = watch(() => { 302 | const { loading, data } = useNormalizedRequest( 303 | '/item/1', 304 | { 305 | store, 306 | client: fakeClient, 307 | normalize: normalizeOptions 308 | } 309 | ) 310 | 311 | const { loading: networkLoading, data: networkData } = useNormalizedRequest( 312 | '/item/1', 313 | { 314 | store, 315 | client: fakeClient, 316 | normalize: normalizeOptions, 317 | skip: () => loading, 318 | policy: 'network-only' 319 | } 320 | ) 321 | 322 | if(loading && !networkLoading) 323 | return
loading…
324 | 325 | if(!loading && networkLoading && data && !networkData) 326 | return
{ data.items['1'].title }
327 | 328 | if(data && networkData) 329 | return
{ data.items['1'].title + ' ' + networkData.items['1'].title }
330 | 331 | return null 332 | }) 333 | 334 | const { getByText } = render() 335 | expect(getByText('loading…')).toBeTruthy() 336 | await waitFor(() => { 337 | expect(getByText('My Item')).toBeTruthy() 338 | }) 339 | await waitFor(() => { 340 | expect(getByText('Updated Title Updated Title')).toBeTruthy() 341 | }) 342 | }) 343 | }) 344 | 345 | describe('useResource', () => { 346 | 347 | it('should fetch a single resource, normalize it and return the data', async () => { 348 | const store = {} 349 | const fakeClient = wretch().middlewares([ 350 | () => () => Promise.resolve({ 351 | ok: true, 352 | json() { 353 | return Promise.resolve(payload) 354 | } 355 | }) 356 | ]) 357 | const Component = () => { 358 | const { loading, data } = useResource( 359 | 'items', 360 | '/item/1', 361 | { 362 | id: 1, 363 | store, 364 | client: fakeClient, 365 | normalize: normalizeOptions 366 | } 367 | ) 368 | 369 | return ( 370 | loading ? 371 |
loading
: 372 |
{ JSON.stringify(data) }
373 | ) 374 | } 375 | 376 | const { getByText, getByTestId } = render() 377 | expect(getByText('loading')).toBeTruthy() 378 | await waitFor(() => { 379 | expect(getByTestId('data-item')).toBeTruthy() 380 | }) 381 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual(normalizedPayload.items['1']) 382 | }) 383 | 384 | it('should fetch multiple resources, normalize them and return the data', async () => { 385 | const store = {} 386 | const fakeClient = wretch().middlewares([ 387 | () => () => Promise.resolve({ 388 | ok: true, 389 | json() { 390 | return Promise.resolve([payload]) 391 | } 392 | }) 393 | ]) 394 | const Component = () => { 395 | const { loading, data } = useResource( 396 | 'items', 397 | '/items', 398 | { 399 | store, 400 | client: fakeClient, 401 | normalize: normalizeOptions 402 | } 403 | ) 404 | 405 | return ( 406 | loading ? 407 |
loading
: 408 |
{ JSON.stringify(data) }
409 | ) 410 | } 411 | 412 | const { getByText, getByTestId } = render() 413 | expect(getByText('loading')).toBeTruthy() 414 | await waitFor(() => { 415 | expect(getByTestId('data-item')).toBeTruthy() 416 | }) 417 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual([normalizedPayload.items['1']]) 418 | }) 419 | 420 | it('should retrieve data from the cache by id', async () => { 421 | const store = createStore({ 422 | item: { 423 | 1: { 424 | id: 1, 425 | title: 'Title' 426 | } 427 | }, 428 | __requests__: { 429 | testKey: { 430 | item: [1] 431 | } 432 | } 433 | }) 434 | 435 | const fakeClient = wretch().middlewares([ 436 | () => () => Promise.resolve({ 437 | ok: true, 438 | async json() { 439 | await sleep() 440 | return { 441 | id: 1, 442 | title: 'Updated title' 443 | } 444 | } 445 | }) 446 | ]) 447 | 448 | const Component = () => { 449 | const { data } = useResource( 450 | 'item', 451 | '/item/1', 452 | { 453 | id: 1, 454 | store, 455 | client: fakeClient, 456 | serialize: () => 'testKey', 457 | policy: 'cache-and-network' 458 | } 459 | ) 460 | 461 | return ( 462 | !data ? 463 |
No data in the cache
: 464 |
{ data && JSON.stringify(data) }
465 | ) 466 | } 467 | 468 | const { getByTestId } = render() 469 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ 470 | id: 1, 471 | title: 'Title' 472 | }) 473 | await waitFor(() => { 474 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ 475 | id: 1, 476 | title: 'Updated title' 477 | }) 478 | }) 479 | }) 480 | 481 | it('should refetch data properly', async () => { 482 | const store = createStore({ 483 | item: { 484 | 1: { 485 | id: 1, 486 | title: 'Title' 487 | } 488 | }, 489 | __requests__: { 490 | testKey: { 491 | item: [1] 492 | } 493 | } 494 | }) 495 | 496 | const fakeClient = wretch().middlewares([ 497 | () => () => Promise.resolve({ 498 | ok: true, 499 | async json() { 500 | await sleep() 501 | return { 502 | id: 1, 503 | title: 'Updated title' 504 | } 505 | } 506 | }) 507 | ]) 508 | 509 | const Component = () => { 510 | const { data, refetch } = useResource( 511 | 'item', 512 | '/item/1', 513 | { 514 | id: 1, 515 | store, 516 | client: fakeClient, 517 | serialize: () => 'testKey', 518 | policy: 'cache-first' 519 | } 520 | ) 521 | 522 | return ( 523 | <> 524 | { !data ? 525 |
No data in the cache
: 526 |
{ data && JSON.stringify(data) }
527 | } 528 | 529 | 530 | ) 531 | } 532 | 533 | const { getByTestId } = render() 534 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ 535 | id: 1, 536 | title: 'Title' 537 | }) 538 | fireEvent.click(getByTestId('refetch-button')) 539 | await waitFor(() => { 540 | expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ 541 | id: 1, 542 | title: 'Updated title' 543 | }) 544 | }) 545 | }) 546 | }) 547 | 548 | describe('Hooks tools', () => { 549 | it('should properly read data from the store using mappings', () => { 550 | const store = { 551 | items: { 552 | 1: { 553 | id: 1, 554 | list: [1, 2] 555 | }, 556 | 2: '…' 557 | } 558 | } 559 | const mappings = { 560 | items: [ 1, 2 ], 561 | none: [ 1 ] 562 | } 563 | const storeFragment = normalizedOperations.read(mappings, store) 564 | expect(storeFragment).toEqual({ 565 | items: { 566 | 1: { 567 | id: 1, 568 | list: [1, 2] 569 | }, 570 | 2: '…' 571 | }, 572 | none: { 573 | 1: null 574 | } 575 | }) 576 | }) 577 | it('should write normalized data into the store', () => { 578 | const store = { 579 | items: { 580 | 1: { 581 | id: 1, 582 | list: [1, 2] 583 | }, 584 | 2: '…' 585 | } 586 | } 587 | normalizedOperations.write({ 588 | items: { 589 | 1: { 590 | id: 1, 591 | number: 1 592 | }, 593 | 2: '!== object' 594 | } 595 | }, store) 596 | 597 | expect(store).toEqual({ 598 | items: { 599 | 1: { 600 | id: 1, 601 | list: [1, 2], 602 | number: 1 603 | }, 604 | 2: '!== object' 605 | } 606 | }) 607 | }) 608 | }) 609 | }) -------------------------------------------------------------------------------- /test/react/utils.js: -------------------------------------------------------------------------------- 1 | export function sleep(ms = 250) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, ms) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /test/websocket.test.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws') 2 | const { 3 | server: hyperactivServer, 4 | client: hyperactivClient 5 | } = require('../src/websocket/server').default 6 | 7 | let wss = null 8 | let hostedObject = null 9 | const clientObjects = { 10 | one: {}, 11 | two: {} 12 | } 13 | 14 | function getClientReflection(hostedObject) { 15 | if(hostedObject instanceof Array) { 16 | return hostedObject 17 | .filter(_ => typeof _ !== 'function') 18 | .map(getClientReflection) 19 | } else if(typeof hostedObject === 'object') { 20 | const reflection = {} 21 | Object.entries(hostedObject).forEach(([ key, value ]) => { 22 | if(typeof value !== 'function') 23 | reflection[key] = getClientReflection(value) 24 | }) 25 | return reflection 26 | } 27 | 28 | return hostedObject 29 | } 30 | 31 | const sleep = (time = 250) => new Promise(resolve => setTimeout(resolve, time)) 32 | 33 | beforeAll(async () => { 34 | wss = hyperactivServer(new WebSocket.Server({ port: 8080 })) 35 | await sleep() 36 | }, 5000) 37 | 38 | test('Host server side without argument', async () => { 39 | const wss = new WebSocket.Server({ port: 8081 }) 40 | const generatedHostedObject = hyperactivServer(wss).host() 41 | await sleep() 42 | expect(generatedHostedObject).toStrictEqual({}) 43 | wss.close() 44 | }) 45 | 46 | test('Host an object server side', async () => { 47 | const baseObject = { 48 | a: 1, 49 | getA: function() { 50 | return hostedObject.a 51 | }, 52 | __remoteMethods: 'getA', 53 | nested: { 54 | b: 2, 55 | getB() { return hostedObject.nested.b }, 56 | getBPlus(number) { return hostedObject.nested.b + number }, 57 | getError() { 58 | throw new Error('bleh') 59 | }, 60 | __remoteMethods: [ 61 | 'getB', 62 | 'getBPlus', 63 | 'getError' 64 | ] 65 | } 66 | } 67 | hostedObject = wss.host(baseObject) 68 | await sleep() 69 | expect(hostedObject).toStrictEqual(baseObject) 70 | }) 71 | 72 | test('Sync the object client side', async () => { 73 | clientObjects.one = hyperactivClient(new WebSocket('ws://localhost:8080')) 74 | await sleep() 75 | expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) 76 | }) 77 | 78 | test('Sync another object client side', async () => { 79 | hyperactivClient(new WebSocket('ws://localhost:8080'), clientObjects.two) 80 | await sleep() 81 | expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) 82 | }) 83 | 84 | test('Mutate server should update client', async () => { 85 | hostedObject.a = 2 86 | hostedObject.a2 = 1 87 | await sleep() 88 | expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) 89 | expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) 90 | }) 91 | 92 | test('Call remote functions', async () => { 93 | const a = await clientObjects.one.getA() 94 | expect(a).toBe(2) 95 | const b = await clientObjects.two.nested.getB() 96 | expect(b).toBe(2) 97 | const bPlusOne = await clientObjects.two.nested.getBPlus(5) 98 | expect(bPlusOne).toBe(7) 99 | return expect(clientObjects.one.nested.getError()).rejects.toMatch('bleh') 100 | }) 101 | 102 | test('autoExportMethods should declare remote methods automatically', async () => { 103 | const wss = new WebSocket.Server({ port: 8081 }) 104 | const baseObj = { a: 1, getA() { return baseObj.a }} 105 | const hosted = hyperactivServer(wss).host(baseObj, { autoExportMethods: true }) 106 | await sleep() 107 | const client = hyperactivClient(new WebSocket('ws://localhost:8081')) 108 | await sleep() 109 | let a = await client.getA() 110 | expect(a).toBe(1) 111 | hosted.a = 2 112 | await sleep() 113 | a = await client.getA() 114 | expect(a).toBe(2) 115 | wss.close() 116 | }) 117 | 118 | afterAll(() => { 119 | wss.close() 120 | }) 121 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "allowJs": true, 7 | "declaration": true, 8 | "emitDeclarationOnly": true, 9 | "declarationMap": true, 10 | "outDir": "types", 11 | "lib": [ 12 | "ES2015", 13 | "DOM" 14 | ] 15 | } 16 | } --------------------------------------------------------------------------------