├── .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 |
3 |
4 | Hyperactiv
5 |
6 |
7 |
8 |
9 |
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 | 
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 |
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 | Red
43 | Green
44 | Blue
45 | Random
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 |
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 |
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 |
106 | Add todo
107 |
108 |
109 | Toggle completion
110 |
111 |
112 | Clear completed
113 |
114 |
115 |
116 |
126 |
127 | )
128 | }))
129 |
130 | const Todo = memo(function Todo({ todo }) {
131 | return (
132 |
133 |
134 | todo.label = event.target.value }/>
138 | todosActions.remove(todo) }
140 | className="button--unstyled"
141 | >✖
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 |
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 | refetch
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 | }
--------------------------------------------------------------------------------