From d4cbdaf96f266d9306090cdc851e9b66bdf8a146 Mon Sep 17 00:00:00 2001 From: Julien Elbaz Date: Mon, 29 Aug 2022 00:44:21 +0200 Subject: [PATCH] add benchmark and improve overall performance - rework the build and improve .mjs nodejs compatibility - batching uses microTasks by default - add batch() function Related to #24 --- .eslintignore | 4 +- .eslintrc | 416 ++++----- .gitignore | 6 +- README.md | 17 + bench/README.md | 8 + bench/layers.mjs | 337 +++++++ bench/package-lock.json | 207 +++++ bench/package.json | 17 + config/common.js | 8 +- config/rollup.classes.config.js | 47 +- config/rollup.config.js | 24 +- config/rollup.handlers.config.js | 23 +- config/rollup.http.config.js | 44 +- config/rollup.react.config.js | 73 +- config/rollup.websocket.config.js | 45 +- package.json | 51 +- src/batcher.js | 32 +- src/classes/index.js | 30 +- src/computed.js | 66 +- src/data.js | 5 +- src/dispose.js | 4 +- src/handlers/all.js | 6 +- src/handlers/debug.js | 10 +- src/handlers/index.js | 6 +- src/handlers/write.js | 28 +- src/http/normalized.js | 78 +- src/http/request.js | 64 +- src/http/resource.js | 80 +- src/http/tools.js | 58 +- src/index.js | 8 +- src/observe.js | 293 +++--- src/react/context/index.js | 64 +- src/react/hooks/context.js | 8 +- src/react/hooks/dependencies.js | 18 +- src/react/hooks/index.js | 4 +- src/react/hooks/useNormalizedRequest.js | 188 ++-- src/react/hooks/useRequest.js | 170 ++-- src/react/hooks/useResource.js | 126 +-- src/react/index.js | 2 +- src/react/watchComponent.js | 50 +- src/react/watchHoc.js | 92 +- src/tools.js | 38 +- src/websocket/browser.js | 64 +- src/websocket/server.js | 166 ++-- test/classes.test.js | 92 +- test/environment.js | 12 +- test/handlers.test.js | 228 ++--- test/http.test.js | 754 +++++++-------- test/index.test.js | 864 +++++++++--------- test/react/components.test.js | 278 +++--- test/react/context.test.js | 344 +++---- test/react/hooks.test.js | 1108 +++++++++++------------ test/react/utils.js | 6 +- test/websocket.test.js | 156 ++-- 54 files changed, 3796 insertions(+), 3131 deletions(-) create mode 100644 bench/README.md create mode 100644 bench/layers.mjs create mode 100644 bench/package-lock.json create mode 100644 bench/package.json diff --git a/.eslintignore b/.eslintignore index 287eb0a..9dd7617 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,2 @@ /test/hyperactiv.js -/dist -/handlers -/websocket \ No newline at end of file +/dist \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index f67345b..1bef3e4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,206 +1,216 @@ { - "parser": "@babel/eslint-parser", - "parserOptions": { - "ecmaVersion": 8, - "sourceType": "module" - }, - "env": { - "es6": true, - "browser": true, - "node": true, - "jest/globals": true - }, - "plugins": ["jest"], - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "settings": { - "react": { - "version": "detect" - } - }, - "rules": { - "no-console": ["warn"], - "no-extra-parens": ["warn", "all"], - "block-spacing": [ - "warn", - "always" - ], - "brace-style": [ - "warn", - "1tbs", - { - "allowSingleLine": true - } - ], - "camelcase": [ - "error", - { - "properties": "never" - } - ], - "comma-dangle": [ - "error", - "never" - ], - "comma-spacing": [ - "error" - ], - "comma-style": [ - "error" - ], - "computed-property-spacing": [ - "error" - ], - "consistent-this": [ - "error" - ], - "no-trailing-spaces": [ - "error" - ], - "no-multiple-empty-lines": [ - "error" - ], - "func-call-spacing": [ - "error" - ], - "indent": [ - "error", - 4, - { - "flatTernaryExpressions": true, - "SwitchCase": 1 - } - ], - "key-spacing": [ - "error", - { - "mode": "minimum" - } - ], - "keyword-spacing": [ - "error", - { - "overrides": { - "if": { - "after": false - }, - "for": { - "after": false - }, - "while": { - "after": false - }, - "catch": { - "after": false - } - } - } - ], - "linebreak-style": [ - "error" - ], - "new-cap": [ - "warn" - ], - "new-parens": [ - "error" - ], - "newline-per-chained-call": [ - "error", - { - "ignoreChainWithDepth": 3 - } - ], - "no-lonely-if": [ - "error" - ], - "no-mixed-spaces-and-tabs": [ - "warn", - "smart-tabs" - ], - "no-unneeded-ternary": [ - "error" - ], - "no-whitespace-before-property": [ - "error" - ], - "operator-linebreak": [ - "warn", - "after" - ], - "quote-props": [ - "error", - "as-needed", - { - "keywords": true - } - ], - "quotes": [ - "error", - "single", - { - "avoidEscape": true - } - ], - "no-unexpected-multiline": [ - "warn" - ], - "semi": [ - "error", - "never" - ], - "space-before-blocks": [ - "error" - ], - "space-before-function-paren": ["error", { - "anonymous": "never", - "named": "never", - "asyncArrow": "always" - }], - "space-in-parens": [ - "error" - ], - "space-infix-ops": [ - "error", - { - "int32Hint": false - } - ], - "spaced-comment": [ - "error", - "always" - ], - "space-unary-ops": [ - "error", - { - "words": true, - "nonwords": false - } - ], - "unicode-bom": [ - "error", - "never" - ], - "arrow-body-style": [ - "error", - "as-needed" - ], - "arrow-parens": [ - "error", - "as-needed" - ], - "arrow-spacing": [ - "error" - ], - "prefer-const": [ - "error" - ], - "prefer-rest-params": [ - "warn" - ], - "react/display-name": 0, - "react/prop-types": 0 + "parser": "@babel/eslint-parser", + "parserOptions": { + "ecmaVersion": 8, + "sourceType": "module" + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "jest/globals": true + }, + "plugins": [ + "jest" + ], + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "settings": { + "react": { + "version": "detect" } + }, + "rules": { + "no-console": [ + "warn" + ], + "no-extra-parens": [ + "warn", + "all" + ], + "block-spacing": [ + "warn", + "always" + ], + "brace-style": [ + "warn", + "1tbs", + { + "allowSingleLine": true + } + ], + "camelcase": [ + "error", + { + "properties": "never" + } + ], + "comma-dangle": [ + "error", + "never" + ], + "comma-spacing": [ + "error" + ], + "comma-style": [ + "error" + ], + "computed-property-spacing": [ + "error" + ], + "consistent-this": [ + "error" + ], + "no-trailing-spaces": [ + "error" + ], + "no-multiple-empty-lines": [ + "error" + ], + "func-call-spacing": [ + "error" + ], + "indent": [ + "error", + 2, + { + "flatTernaryExpressions": true, + "SwitchCase": 1 + } + ], + "key-spacing": [ + "error", + { + "mode": "minimum" + } + ], + "keyword-spacing": [ + "error", + { + "overrides": { + "if": { + "after": false + }, + "for": { + "after": false + }, + "while": { + "after": false + }, + "catch": { + "after": false + } + } + } + ], + "linebreak-style": [ + "error" + ], + "new-cap": [ + "warn" + ], + "new-parens": [ + "error" + ], + "newline-per-chained-call": [ + "error", + { + "ignoreChainWithDepth": 3 + } + ], + "no-lonely-if": [ + "error" + ], + "no-mixed-spaces-and-tabs": [ + "warn", + "smart-tabs" + ], + "no-unneeded-ternary": [ + "error" + ], + "no-whitespace-before-property": [ + "error" + ], + "operator-linebreak": [ + "warn", + "after" + ], + "quote-props": [ + "error", + "as-needed", + { + "keywords": true + } + ], + "quotes": [ + "error", + "single", + { + "avoidEscape": true + } + ], + "no-unexpected-multiline": [ + "warn" + ], + "semi": [ + "error", + "never" + ], + "space-before-blocks": [ + "error" + ], + "space-before-function-paren": [ + "error", + { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + } + ], + "space-in-parens": [ + "error" + ], + "space-infix-ops": [ + "error", + { + "int32Hint": false + } + ], + "spaced-comment": [ + "error", + "always" + ], + "space-unary-ops": [ + "error", + { + "words": true, + "nonwords": false + } + ], + "unicode-bom": [ + "error", + "never" + ], + "arrow-body-style": [ + "error", + "as-needed" + ], + "arrow-parens": [ + "error", + "as-needed" + ], + "arrow-spacing": [ + "error" + ], + "prefer-const": [ + "error" + ], + "prefer-rest-params": [ + "warn" + ], + "react/display-name": 0, + "react/prop-types": 0 + } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7818fe..170e6a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ # Build /dist -/handlers -/react -/classes -/websocket /types /temp @@ -43,7 +39,7 @@ bower_components build/Release # Dependency directories -node_modules/ +node_modules jspm_packages/ # Typescript v1 declaration files diff --git a/README.md b/README.md index 6ae3ac4..3b95755 100644 --- a/README.md +++ b/README.md @@ -481,3 +481,20 @@ Will remove the computed function from the reactive Maps (the next time an bound ```ts dispose(Function) => void ``` + +### batch + +_Only when observables are created with the `{batch: …}` flag_ + +Will perform accumulated b.ed computations instantly. + +```ts +const obj = observe({ a: 0, b: 0 }, { batch: true }) +computed(() => obj.a = obj.b) +obj.b++ +obj.b++ +console.log(obj.a) // => 0 +batch() +console.log(obj.a) // => 2 +``` + diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..a798e40 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,8 @@ +## Benchmark + +_Adapted from: https://github.com/maverick-js/observables/tree/main/bench_ + +```bash +npm i +npm run start +``` \ No newline at end of file diff --git a/bench/layers.mjs b/bench/layers.mjs new file mode 100644 index 0000000..8d068c1 --- /dev/null +++ b/bench/layers.mjs @@ -0,0 +1,337 @@ +/* eslint-disable new-cap */ +/* eslint-disable no-console */ +/** + * Adapted from: https://github.com/maverick-js/observables/tree/main/bench + */ + + +import kleur from 'kleur' +import * as cellx from 'cellx' + +import * as Sjs from 's-js' +import * as mobx from 'mobx' +import * as maverick from '@maverick-js/observables' +import hyperactiv from 'hyperactiv' +import Table from 'cli-table' + +const RUNS_PER_TIER = 25 +const LAYER_TIERS = [10, 100, 500, 1000, 2000, 2500, 5000, 10000] + +const sum = array => array.reduce((a, b) => a + b, 0) +const avg = array => sum(array) / array.length || 0 + +const SOLUTIONS = { + 10: [2, 4, -2, -3], + 100: [-2, -4, 2, 3], + 500: [-2, 1, -4, -4], + 1000: [-2, -4, 2, 3], + 2000: [-2, 1, -4, -4], + 2500: [-2, -4, 2, 3], + 5000: [-2, 1, -4, -4], + 10000: [ -2, -4, 2, 3 ] +} + +/** + * @param {number} layers + * @param {number[]} answer + */ +const isSolution = (layers, answer) => answer.every((s, i) => s === SOLUTIONS[layers][i]) + +async function main() { + const report = { + cellx: { fn: runCellx, runs: [] }, + hyperactiv: { fn: runHyperactiv, runs: [] }, + maverick: { fn: runMaverick, runs: [], avg: [] }, + mobx: { fn: runMobx, runs: [] }, + S: { fn: runS, runs: [] } + } + + for(const lib of Object.keys(report)) { + const current = report[lib] + + for(let i = 0; i < LAYER_TIERS.length; i += 1) { + const layers = LAYER_TIERS[i] + const runs = [] + let result = null + + for(let j = 0; j < RUNS_PER_TIER; j += 1) { + result = await start(current.fn, layers) + if(typeof result !== 'number') { + break + } + runs.push(await start(current.fn, layers)) + } + if(typeof result !== 'number') { + current.runs[i] = result + } else { + current.runs[i] = avg(runs) * 1000 + } + } + } + + const table = new Table({ + head: ['', ...LAYER_TIERS.map(n => kleur.bold(kleur.cyan(n)))] + }) + + for(let i = 0; i < LAYER_TIERS.length; i += 1) { + let min = Infinity, + max = -1, + fastestLib, + slowestLib + + + for(const lib of Object.keys(report)) { + const time = report[lib].runs[i] + + if(typeof time !== 'number') { + continue + } + + if(time < min) { + min = time + fastestLib = lib + } + + if(time > max) { + max = time + slowestLib = lib + } + } + + if(fastestLib && typeof report[fastestLib].runs[i] === 'number') + report[fastestLib].runs[i] = kleur.green(report[fastestLib].runs[i].toFixed(2)) + if(slowestLib && typeof report[slowestLib].runs[i] === 'number') + report[slowestLib].runs[i] = kleur.red(report[slowestLib].runs[i].toFixed(2)) + } + + for(const lib of Object.keys(report)) { + table.push([ + kleur.magenta(lib), + ...report[lib].runs.map(n => typeof n === 'number' ? n.toFixed(2) : n) + ]) + } + + console.log(table.toString()) +} + +async function start(runner, layers) { + return new Promise(done => { + runner(layers, done) + }).catch(error => { + console.error(error) + return 'error' + }) +} + +/** + * @see {@link https://github.com/Riim/cellx} + */ +function runCellx(layers, done) { + const start = { + a: new cellx.Cell(1), + b: new cellx.Cell(2), + c: new cellx.Cell(3), + d: new cellx.Cell(4) + } + + let layer = start + + for(let i = layers; i--;) { + layer = (m => { + const props = { + a: new cellx.Cell(() => m.b.get()), + b: new cellx.Cell(() => m.a.get() - m.c.get()), + c: new cellx.Cell(() => m.b.get() + m.d.get()), + d: new cellx.Cell(() => m.c.get()) + } + + props.a.on('change', function() { }) + props.b.on('change', function() { }) + props.c.on('change', function() { }) + props.d.on('change', function() { }) + + return props + })(layer) + } + + const startTime = performance.now() + const end = layer + + start.a.set(4) + start.b.set(3) + start.c.set(2) + start.d.set(1) + + const solution = [end.a.get(), end.b.get(), end.c.get(), end.d.get()] + const endTime = performance.now() - startTime + + done(isSolution(layers, solution) ? endTime : 'wrong') +} + +/** + * @see {@link https://github.com/maverick-js/observables} + */ +function runMaverick(layers, done) { + maverick.root(dispose => { + const start = { + a: maverick.observable(1), + b: maverick.observable(2), + c: maverick.observable(3), + d: maverick.observable(4) + } + + let layer = start + + for(let i = layers; i--;) { + layer = (m => ({ + a: maverick.computed(() => m.b()), + b: maverick.computed(() => m.a() - m.c()), + c: maverick.computed(() => m.b() + m.d()), + d: maverick.computed(() => m.c()) + }))(layer) + } + + const startTime = performance.now() + const end = layer + + start.a.set(4), start.b.set(3), start.c.set(2), start.d.set(1) + + const solution = [end.a(), end.b(), end.c(), end.d()] + const endTime = performance.now() - startTime + + dispose() + done(isSolution(layers, solution) ? endTime : 'wrong') + }) +} + +/** + * @see {@link https://github.com/adamhaile/S} + */ +function runS(layers, done) { + const S = Sjs.default + + S.root(() => { + const start = { + a: S.data(1), + b: S.data(2), + c: S.data(3), + d: S.data(4) + } + + let layer = start + + for(let i = layers; i--;) { + layer = (m => { + const props = { + a: S(() => m.b()), + b: S(() => m.a() - m.c()), + c: S(() => m.b() + m.d()), + d: S(() => m.c()) + } + + return props + })(layer) + } + + const startTime = performance.now() + const end = layer + + start.a(4), start.b(3), start.c(2), start.d(1) + + const solution = [end.a(), end.b(), end.c(), end.d()] + const endTime = performance.now() - startTime + + done(isSolution(layers, solution) ? endTime : 'wrong') + }) +} + +/** + * @see {@link https://github.com/mobxjs/mobx} + */ +function runMobx(layers, done) { + mobx.configure({ + enforceActions: 'never' + }) + const start = mobx.observable({ + a: mobx.observable(1), + b: mobx.observable(2), + c: mobx.observable(3), + d: mobx.observable(4) + }) + let layer = start + + for(let i = layers; i--;) { + layer = (prev => { + const next = mobx.observable({ + a: mobx.computed(() => prev.b.get()), + b: mobx.computed(() => prev.a.get() - prev.c.get()), + c: mobx.computed(() => prev.b.get() + prev.d.get()), + d: mobx.computed(() => prev.c.get()) + }) + + return next + })(layer) + } + + const end = layer + + const startTime = performance.now() + + start.a.set(4) + start.b.set(3) + start.c.set(2) + start.d.set(1) + + const solution = [ + end.a.get(), + end.b.get(), + end.c.get(), + end.d.get() + ] + const endTime = performance.now() - startTime + done(isSolution(layers, solution) ? endTime : 'wrong') + +} + +function runHyperactiv(layers, done) { + const start = hyperactiv.observe({ + a: 1, + b: 2, + c: 3, + d: 4 + }, { batch: true }) + let layer = start + + for(let i = layers; i--;) { + layer = (prev => { + const next = hyperactiv.observe({}, { batch: true }) + hyperactiv.computed(() => next.a = prev.b, { disableTracking: true }) + hyperactiv.computed(() => next.b = prev.a - prev.c, { disableTracking: true }) + hyperactiv.computed(() => next.c = prev.b + prev.d, { disableTracking: true }) + hyperactiv.computed(() => next.d = prev.c, { disableTracking: true }) + return next + })(layer) + } + + const end = layer + + const startTime = performance.now() + + start.a = 4 + start.b = 3 + start.c = 2 + start.d = 1 + + hyperactiv.batch() + + const solution = [ + end.a, + end.b, + end.c, + end.d + ] + const endTime = performance.now() - startTime + done(isSolution(layers, solution) ? endTime : 'wrong') +} + +main() diff --git a/bench/package-lock.json b/bench/package-lock.json new file mode 100644 index 0000000..dd25adf --- /dev/null +++ b/bench/package-lock.json @@ -0,0 +1,207 @@ +{ + "name": "benchmarks", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "benchmarks", + "version": "0.0.0", + "dependencies": { + "@maverick-js/observables": "^4.3.2", + "cellx": "^1.10.29", + "cli-table": "^0.3.11", + "hyperactiv": "file:..", + "kleur": "^4.1.5", + "mobx": "^6.6.1", + "s-js": "^0.4.9" + } + }, + "..": { + "version": "0.10.3", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.17.10", + "@babel/eslint-parser": "^7.17.0", + "@babel/preset-env": "^7.17.10", + "@babel/preset-react": "^7.16.7", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.2.0", + "@types/jest": "^27.5.0", + "babel-jest": "^28.1.0", + "eslint": "^8.15.0", + "eslint-plugin-jest": "^26.1.5", + "eslint-plugin-react": "^7.29.4", + "jest": "^28.1.0", + "jest-environment-jsdom": "^28.1.0", + "node-fetch": "^2", + "normaliz": "^0.2.0", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "react-test-renderer": "^18.1.0", + "rimraf": "^3.0.2", + "rollup": "^2.72.0", + "rollup-plugin-terser": "^7.0.2", + "typescript": "^4.6.4", + "wretch": "^1.7.9", + "ws": "^7" + } + }, + "node_modules/@maverick-js/observables": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maverick-js/observables/-/observables-4.3.2.tgz", + "integrity": "sha512-8xeifk2D6/2ed8RyhBfO8q90Sd55ODTBYULg9BMpJX3zICho4oPyPAAHq3A0MFg4gdEjNLGiCqNKyXu3JDRVeg==", + "dependencies": { + "@maverick-js/scheduler": "^1.0.1" + } + }, + "node_modules/@maverick-js/scheduler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@maverick-js/scheduler/-/scheduler-1.0.1.tgz", + "integrity": "sha512-4THb+ZPMrY97WvROAxh4yFXVdnQxOLqms/lxYzgQFjoGWDp7AOiGAkIb++kUZA4npQ5I2lJ1DsL+o6kofNW8oA==" + }, + "node_modules/@riim/next-tick": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@riim/next-tick/-/next-tick-1.2.6.tgz", + "integrity": "sha512-Grcu9OhD5Ohk/x2i0DwxB37Xf7ElmdBA5dGSDTNFNyiMPsMwc+XjciH+wgWd6vIq6bEtMPmaSXUm3i9q/TEyag==" + }, + "node_modules/cellx": { + "version": "1.10.29", + "resolved": "https://registry.npmjs.org/cellx/-/cellx-1.10.29.tgz", + "integrity": "sha512-fUdUecXe7UY4dY3il26DYfTl7ugNkGAycB1w5oc/GE0KRHZcT03ZgnKSXt/Xpt/7eYxqSXj0sFA8B/RIEpyH1A==", + "dependencies": { + "@riim/next-tick": "1.2.6" + } + }, + "node_modules/cli-table": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", + "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", + "dependencies": { + "colors": "1.0.3" + }, + "engines": { + "node": ">= 0.2.0" + } + }, + "node_modules/colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/hyperactiv": { + "resolved": "..", + "link": true + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mobx": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.6.1.tgz", + "integrity": "sha512-7su3UZv5JF+ohLr2opabjbUAERfXstMY+wiBtey8yNAPoB8H187RaQXuhFjNkH8aE4iHbDWnhDFZw0+5ic4nGQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/s-js": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", + "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==" + } + }, + "dependencies": { + "@maverick-js/observables": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maverick-js/observables/-/observables-4.3.2.tgz", + "integrity": "sha512-8xeifk2D6/2ed8RyhBfO8q90Sd55ODTBYULg9BMpJX3zICho4oPyPAAHq3A0MFg4gdEjNLGiCqNKyXu3JDRVeg==", + "requires": { + "@maverick-js/scheduler": "^1.0.1" + } + }, + "@maverick-js/scheduler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@maverick-js/scheduler/-/scheduler-1.0.1.tgz", + "integrity": "sha512-4THb+ZPMrY97WvROAxh4yFXVdnQxOLqms/lxYzgQFjoGWDp7AOiGAkIb++kUZA4npQ5I2lJ1DsL+o6kofNW8oA==" + }, + "@riim/next-tick": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@riim/next-tick/-/next-tick-1.2.6.tgz", + "integrity": "sha512-Grcu9OhD5Ohk/x2i0DwxB37Xf7ElmdBA5dGSDTNFNyiMPsMwc+XjciH+wgWd6vIq6bEtMPmaSXUm3i9q/TEyag==" + }, + "cellx": { + "version": "1.10.29", + "resolved": "https://registry.npmjs.org/cellx/-/cellx-1.10.29.tgz", + "integrity": "sha512-fUdUecXe7UY4dY3il26DYfTl7ugNkGAycB1w5oc/GE0KRHZcT03ZgnKSXt/Xpt/7eYxqSXj0sFA8B/RIEpyH1A==", + "requires": { + "@riim/next-tick": "1.2.6" + } + }, + "cli-table": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", + "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", + "requires": { + "colors": "1.0.3" + } + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==" + }, + "hyperactiv": { + "version": "file:..", + "requires": { + "@babel/core": "^7.17.10", + "@babel/eslint-parser": "^7.17.0", + "@babel/preset-env": "^7.17.10", + "@babel/preset-react": "^7.16.7", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.2.0", + "@types/jest": "^27.5.0", + "babel-jest": "^28.1.0", + "eslint": "^8.15.0", + "eslint-plugin-jest": "^26.1.5", + "eslint-plugin-react": "^7.29.4", + "jest": "^28.1.0", + "jest-environment-jsdom": "^28.1.0", + "node-fetch": "^2", + "normaliz": "^0.2.0", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "react-test-renderer": "^18.1.0", + "rimraf": "^3.0.2", + "rollup": "^2.72.0", + "rollup-plugin-terser": "^7.0.2", + "typescript": "^4.6.4", + "wretch": "^1.7.9", + "ws": "^7" + } + }, + "kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, + "mobx": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.6.1.tgz", + "integrity": "sha512-7su3UZv5JF+ohLr2opabjbUAERfXstMY+wiBtey8yNAPoB8H187RaQXuhFjNkH8aE4iHbDWnhDFZw0+5ic4nGQ==" + }, + "s-js": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", + "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==" + } + } +} diff --git a/bench/package.json b/bench/package.json new file mode 100644 index 0000000..781e263 --- /dev/null +++ b/bench/package.json @@ -0,0 +1,17 @@ +{ + "name": "benchmarks", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "node layers.mjs" + }, + "dependencies": { + "@maverick-js/observables": "^4.3.2", + "cellx": "^1.10.29", + "cli-table": "^0.3.11", + "hyperactiv": "file:..", + "kleur": "^4.1.5", + "mobx": "^6.6.1", + "s-js": "^0.4.9" + } +} \ No newline at end of file diff --git a/config/common.js b/config/common.js index 3691836..47746b1 100644 --- a/config/common.js +++ b/config/common.js @@ -2,12 +2,8 @@ import path from 'path' import { terser } from 'rollup-plugin-terser' export const HYPERACTIV_PATH = path.resolve(__dirname, '../src/index.js') -export const IS_TEST_BUILD = process.env.TEST_BUILD export const plugins = [ - terser(IS_TEST_BUILD ? { - // Better compatibility with code coverage lib. - compress: false - } : undefined) + terser() ] -export const sourcemap = IS_TEST_BUILD ? 'inline' : true +export const sourcemap = true diff --git a/config/rollup.classes.config.js b/config/rollup.classes.config.js index 16f58df..149caeb 100644 --- a/config/rollup.classes.config.js +++ b/config/rollup.classes.config.js @@ -1,21 +1,34 @@ -import { IS_TEST_BUILD, HYPERACTIV_PATH, plugins, sourcemap } from './common' +import { HYPERACTIV_PATH, plugins, sourcemap } from './common' export default { - input: './src/classes/index.js', - output: { - file: IS_TEST_BUILD ? 'classes/classes.js' : 'classes/index.js', - format: 'umd', - name: 'hyperactiv-classes', - sourcemap, - globals: { - [HYPERACTIV_PATH]: 'hyperactiv' - }, - paths: { - [HYPERACTIV_PATH]: 'hyperactiv' - } + input: './src/classes/index.js', + output: [ + { + file: 'dist/classes/index.js', + format: 'umd', + name: 'hyperactiv-classes', + sourcemap, + globals: { + [HYPERACTIV_PATH]: 'hyperactiv' + }, + paths: { + [HYPERACTIV_PATH]: 'hyperactiv' + } }, - external: [ - HYPERACTIV_PATH - ], - plugins + { + file: 'dist/classes/index.mjs', + format: 'es', + sourcemap, + globals: { + [HYPERACTIV_PATH]: 'hyperactiv' + }, + paths: { + [HYPERACTIV_PATH]: 'hyperactiv' + } + } + ], + external: [ + HYPERACTIV_PATH + ], + plugins } diff --git a/config/rollup.config.js b/config/rollup.config.js index 4addf25..b06e8a5 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -1,12 +1,18 @@ -import { IS_TEST_BUILD, plugins, sourcemap } from './common' +import { plugins, sourcemap } from './common' export default { - input: './src/index.js', - output: { - file: IS_TEST_BUILD ? 'dist/hyperactiv.js' : 'dist/index.js', - format: 'umd', - name: 'hyperactiv', - sourcemap - }, - plugins + input: './src/index.js', + output: [ + { + file: 'dist/index.js', + format: 'umd', + name: 'hyperactiv', + sourcemap + }, { + file: 'dist/index.mjs', + format: 'es', + sourcemap + } + ], + plugins } diff --git a/config/rollup.handlers.config.js b/config/rollup.handlers.config.js index 2906f83..3d4fd59 100644 --- a/config/rollup.handlers.config.js +++ b/config/rollup.handlers.config.js @@ -1,12 +1,19 @@ -import { IS_TEST_BUILD, plugins, sourcemap } from './common' +import { plugins, sourcemap } from './common' export default { - input: './src/handlers/index.js', - output: { - file: IS_TEST_BUILD ? 'handlers/handlers.js' : 'handlers/index.js', - format: 'umd', - name: 'hyperactiv-handlers', - sourcemap + input: './src/handlers/index.js', + output: [ + { + file: 'dist/handlers/index.js', + format: 'umd', + name: 'hyperactiv-handlers', + sourcemap }, - plugins + { + file: 'dist/handlers/index.mjs', + format: 'es', + sourcemap + } + ], + plugins } diff --git a/config/rollup.http.config.js b/config/rollup.http.config.js index aeaf774..f3db5aa 100644 --- a/config/rollup.http.config.js +++ b/config/rollup.http.config.js @@ -1,20 +1,30 @@ -import { IS_TEST_BUILD, plugins, sourcemap } from './common' +import { plugins, sourcemap } from './common' export default { - input: './src/http/index.js', - output: { - file: IS_TEST_BUILD ? 'http/http.js' : 'http/index.js', - format: 'umd', - name: 'hyperactiv-http', - globals: { - wretch: 'wretch', - normaliz: 'normaliz' - }, - sourcemap - }, - plugins, - external: [ - 'wretch', - 'normaliz' - ] + input: './src/http/index.js', + output: [ + { + file: 'dist/http/index.js', + format: 'umd', + name: 'hyperactiv-http', + globals: { + wretch: 'wretch', + normaliz: 'normaliz' + }, + sourcemap + }, { + file: 'dist/http/index.mjs', + format: 'es', + globals: { + wretch: 'wretch', + normaliz: 'normaliz' + }, + sourcemap + } + ], + plugins, + external: [ + 'wretch', + 'normaliz' + ] } diff --git a/config/rollup.react.config.js b/config/rollup.react.config.js index 55f6424..1cbdf82 100644 --- a/config/rollup.react.config.js +++ b/config/rollup.react.config.js @@ -1,31 +1,48 @@ -import { IS_TEST_BUILD, HYPERACTIV_PATH, plugins, sourcemap } from './common' +import { HYPERACTIV_PATH, plugins, sourcemap } from './common' export default { - input: './src/react/index.js', - output: { - file: IS_TEST_BUILD ? 'react/react.js' : 'react/index.js', - format: 'umd', - name: 'react-hyperactiv', - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - 'react-dom/server': 'ReactDOMServer', - wretch: 'wretch', - normaliz: 'normaliz', - [HYPERACTIV_PATH]: 'hyperactiv' - }, - paths: { - [HYPERACTIV_PATH]: 'hyperactiv' - }, - sourcemap - }, - plugins, - external: [ - 'react', - 'react-dom', - 'react-dom/server', - 'wretch', - 'normaliz', - HYPERACTIV_PATH - ] + input: './src/react/index.js', + output: [ + { + file: 'dist/react/index.js', + format: 'umd', + name: 'react-hyperactiv', + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react-dom/server': 'ReactDOMServer', + wretch: 'wretch', + normaliz: 'normaliz', + [HYPERACTIV_PATH]: 'hyperactiv' + }, + paths: { + [HYPERACTIV_PATH]: 'hyperactiv' + }, + sourcemap + }, { + file: 'dist/react/index.mjs', + format: 'es', + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react-dom/server': 'ReactDOMServer', + wretch: 'wretch', + normaliz: 'normaliz', + [HYPERACTIV_PATH]: 'hyperactiv' + }, + paths: { + [HYPERACTIV_PATH]: 'hyperactiv' + }, + sourcemap + } + ], + plugins, + external: [ + 'react', + 'react-dom', + 'react-dom/server', + 'wretch', + 'normaliz', + HYPERACTIV_PATH + ] } diff --git a/config/rollup.websocket.config.js b/config/rollup.websocket.config.js index 7dac0e8..55612b0 100644 --- a/config/rollup.websocket.config.js +++ b/config/rollup.websocket.config.js @@ -1,29 +1,36 @@ -import { IS_TEST_BUILD, plugins, sourcemap } from './common' +import { plugins, sourcemap } from './common' const serverBuild = { - input: './src/websocket/server.js', - output: { - file: IS_TEST_BUILD ? 'websocket/server.full.js' : 'websocket/server.js', - format: 'cjs', - name: 'hyperactiv-websocket', - sourcemap, - exports: 'default' + input: './src/websocket/server.js', + output: [ + { + file: 'dist/websocket/server.js', + format: 'cjs', + name: 'hyperactiv-websocket', + sourcemap, + exports: 'default' }, - plugins + { + file: 'dist/websocket/server.mjs', + format: 'es', + sourcemap + } + ], + plugins } const browserBuild = { - input: './src/websocket/browser.js', - output: { - file: IS_TEST_BUILD ? 'websocket/browser.full.js' : 'websocket/browser.js', - format: 'umd', - name: 'hyperactiv-websocket', - sourcemap - }, - plugins + input: './src/websocket/browser.js', + output: { + file: 'dist/websocket/browser.js', + format: 'umd', + name: 'hyperactiv-websocket', + sourcemap + }, + plugins } export default [ - serverBuild, - browserBuild + serverBuild, + browserBuild ] diff --git a/package.json b/package.json index fe4f4c4..7d8a723 100644 --- a/package.json +++ b/package.json @@ -7,31 +7,37 @@ "types": "./types/index.d.ts", "typesVersions": { "*": { - "react": [ + "src": [ + "./types/index.d.ts" + ], + "dist": [ + "./types/index.d.ts" + ], + "dist/react": [ "./types/react/index.d.ts" ], "src/react": [ "./types/react/index.d.ts" ], - "classes": [ + "dist/classes": [ "./types/classes/index.d.ts" ], "src/classes": [ "./types/classes/index.d.ts" ], - "handlers": [ + "dist/handlers": [ "./types/handlers/index.d.ts" ], "src/handlers": [ "./types/handlers/index.d.ts" ], - "http": [ + "dist/http": [ "./types/http/index.d.ts" ], "src/http": [ "./types/http/index.d.ts" ], - "websocket": [ + "dist/websocket": [ "./types/websocket/index.d.ts" ], "src/websocket": [ @@ -41,28 +47,31 @@ }, "exports": { ".": { - "import": "./src/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./react": { - "import": "./src/react/index.js", - "require": "./react/index.js" + "import": "./dist/react/index.mjs", + "require": "./dist/react/index.js" }, "./classes": { - "import": "./src/classes/index.js", - "require": "./classes/index.js" + "import": "./dist/classes/index.mjs", + "require": "./dist/classes/index.js" }, "./handlers": { - "import": "./src/handlers/index.js", - "require": "./handlers/index.js" + "import": "./dist/handlers/index.mjs", + "require": "./dist/handlers/index.js" }, "./http": { - "import": "./src/http/index.js", - "require": "./http/index.js" + "import": "./dist/http/index.mjs", + "require": "./dist/http/index.js" }, - "./websocket": { - "import": "./src/websocket/index.js", - "require": "./websocket/index.js" + "./websocket/server": { + "import": "./dist/websocket/server.mjs", + "require": "./dist/websocket/server.js" + }, + "./websocket/browser": { + "default": "./dist/websocket/browser.js" }, "./package.json": "./package.json" }, @@ -73,11 +82,7 @@ "files": [ "src", "types", - "dist", - "handlers", - "react", - "classes", - "websocket" + "dist" ], "scripts": { "start": "npm run lint && npm run build && npm run test", @@ -92,7 +97,7 @@ "build:classes": "rollup -c config/rollup.classes.config.js", "build:websocket": "rollup -c config/rollup.websocket.config.js", "test": "jest", - "clean": "rimraf {dist,react,handlers,websocket,classes,types}", + "clean": "rimraf {dist,types}", "prepublishOnly": "npm start" }, "keywords": [ diff --git a/src/batcher.js b/src/batcher.js index bcd1864..86b533c 100644 --- a/src/batcher.js +++ b/src/batcher.js @@ -1,16 +1,26 @@ -let timeout = null -const queue = new Set() -function process() { - for(const task of queue) { - task() - } - queue.clear() - timeout = null +let queue = null + +/** + * Will perform batched computations instantly. + */ +export function process() { + if(!queue) + return + for(const task of queue) { + task() + } + queue = null } export function enqueue(task, batch) { - if(timeout === null) - timeout = setTimeout(process, batch === true ? 0 : batch) - queue.add(task) + if(queue === null) { + queue = new Set() + if(batch === true) { + queueMicrotask(process) + } else { + setTimeout(process, batch) + } + } + queue.add(task) } diff --git a/src/classes/index.js b/src/classes/index.js index 5bab0f1..6b51bd2 100644 --- a/src/classes/index.js +++ b/src/classes/index.js @@ -2,23 +2,23 @@ import hyperactiv from '../index.js' const { observe, computed, dispose } = hyperactiv export class Observable { - constructor(data = {}, options) { - Object.assign(this, data) - Object.defineProperty(this, '__computed', { value: [], enumerable: false }) - return observe(this, Object.assign({ bubble: true }, options)) - } + constructor(data = {}, options) { + Object.assign(this, data) + Object.defineProperty(this, '__computed', { value: [], enumerable: false }) + return observe(this, Object.assign({ bubble: true }, options)) + } - computed(fn, opt) { - this.__computed.push(computed(fn.bind(this), opt)) - } + computed(fn, opt) { + this.__computed.push(computed(fn.bind(this), opt)) + } - onChange(fn) { - this.__handler = fn - } + onChange(fn) { + this.__handler = fn + } - dispose() { - while(this.__computed.length) { - dispose(this.__computed.pop()) - } + dispose() { + while(this.__computed.length) { + dispose(this.__computed.pop()) } + } } diff --git a/src/computed.js b/src/computed.js index f01ad48..cde4134 100644 --- a/src/computed.js +++ b/src/computed.js @@ -1,5 +1,5 @@ import { data } from './data.js' -const { computedStack, computedDependenciesTracker } = data +const { computedStack, trackerSymbol } = data /** * @typedef {Object} ComputedArguments - Computed Arguments. @@ -24,39 +24,37 @@ const { computedStack, computedDependenciesTracker } = data * @param {Options} options */ export function computed(wrappedFunction, { autoRun = true, callback, bind, disableTracking = false } = {}) { - // Proxify the function in order to intercept the calls - const proxy = new Proxy(wrappedFunction, { - apply(target, thisArg, argsList) { - function observeComputation(fun) { - // Track object and object properties accessed during this function call - if(!disableTracking) { - computedDependenciesTracker.set(callback || proxy, new WeakMap()) - } - // Store into the stack a reference to the computed function - computedStack.unshift(callback || proxy) - // Run the computed function - or the async function - const result = fun ? - fun() : - target.apply(bind || thisArg, argsList) - // Remove the reference - computedStack.shift() - // Return the result - return result - } - - // Inject the computeAsync argument which is used to manually declare when the computation takes part - argsList.push({ - computeAsync: function(target) { return observeComputation(target) } - }) - - return observeComputation() - } - }) - - // If autoRun, then call the function at once - if(autoRun) { - proxy() + function observeComputation(fun, argsList = []) { + const target = callback || wrapper + // Track object and object properties accessed during this function call + if(!disableTracking) { + target[trackerSymbol] = new WeakMap() } + // Store into the stack a reference to the computed function + computedStack.unshift(target) + // Inject the computeAsync argument which is used to manually declare when the computation takes part + if(argsList.length > 0) { + argsList = [...argsList, computeAsyncArg] + } else { + argsList = [computeAsyncArg] + } + // Run the computed function - or the async function + const result = + fun ? fun() : + bind ? wrappedFunction.apply(bind, argsList) : + wrappedFunction(...argsList) + // Remove the reference + computedStack.shift() + // Return the result + return result + } + const computeAsyncArg = { computeAsync: observeComputation } + const wrapper = (...argsList) => observeComputation(null, argsList) + + // If autoRun, then call the function at once + if(autoRun) { + wrapper() + } - return proxy + return wrapper } diff --git a/src/data.js b/src/data.js index 23fe05a..55e39f2 100644 --- a/src/data.js +++ b/src/data.js @@ -1,5 +1,4 @@ export const data = { - computedStack: [], - observersMap: new WeakMap(), - computedDependenciesTracker: new WeakMap() + computedStack: [], + trackerSymbol: Symbol('tracker') } diff --git a/src/dispose.js b/src/dispose.js index 4b5bde4..0198259 100644 --- a/src/dispose.js +++ b/src/dispose.js @@ -6,6 +6,6 @@ import { data } from './data.js' * @param {Function} computedFunction */ export function dispose(computedFunction) { - data.computedDependenciesTracker.delete(computedFunction) - return computedFunction.__disposed = true + computedFunction[data.trackerSymbol] = null + return computedFunction.__disposed = true } diff --git a/src/handlers/all.js b/src/handlers/all.js index 1d04d9f..6e7375a 100644 --- a/src/handlers/all.js +++ b/src/handlers/all.js @@ -1,5 +1,5 @@ export const allHandler = function(handlers) { - return Array.isArray(handlers) ? - (keys, value, proxy) => handlers.forEach(fn => fn(keys, value, proxy)) : - handlers + return Array.isArray(handlers) ? + (keys, value, proxy) => handlers.forEach(fn => fn(keys, value, proxy)) : + handlers } \ No newline at end of file diff --git a/src/handlers/debug.js b/src/handlers/debug.js index 5b0b93b..bc45d19 100644 --- a/src/handlers/debug.js +++ b/src/handlers/debug.js @@ -1,7 +1,7 @@ export const debugHandler = function(logger) { - logger = logger || console - return function(props, value) { - const keys = props.map(prop => Number.isInteger(Number.parseInt(prop)) ? `[${prop}]` : `.${prop}`).join('').substr(1) - logger.log(`${keys} = ${JSON.stringify(value, null, '\t')}`) - } + logger = logger || console + return function(props, value) { + const keys = props.map(prop => Number.isInteger(Number.parseInt(prop)) ? `[${prop}]` : `.${prop}`).join('').substr(1) + logger.log(`${keys} = ${JSON.stringify(value, null, '\t')}`) + } } \ No newline at end of file diff --git a/src/handlers/index.js b/src/handlers/index.js index 9bd2872..01336f3 100644 --- a/src/handlers/index.js +++ b/src/handlers/index.js @@ -3,7 +3,7 @@ import { debugHandler } from './debug.js' import { writeHandler } from './write.js' export default { - write: writeHandler, - debug: debugHandler, - all: allHandler + write: writeHandler, + debug: debugHandler, + all: allHandler } \ No newline at end of file diff --git a/src/handlers/write.js b/src/handlers/write.js index e8cb40e..68d2be0 100644 --- a/src/handlers/write.js +++ b/src/handlers/write.js @@ -1,19 +1,19 @@ const getWriteContext = function(prop) { - return Number.isInteger(Number.parseInt(prop, 10)) ? [] : {} + return Number.isInteger(Number.parseInt(prop, 10)) ? [] : {} } export const writeHandler = function(target) { - if(!target) - throw new Error('writeHandler needs a proper target !') - return function(props, value) { - value = typeof value === 'object' ? - JSON.parse(JSON.stringify(value)) : - value - for(let i = 0; i < props.length - 1; i++) { - var prop = props[i] - if(typeof target[prop] === 'undefined') - target[prop] = getWriteContext(props[i + 1]) - target = target[prop] - } - target[props[props.length - 1]] = value + if(!target) + throw new Error('writeHandler needs a proper target !') + return function(props, value) { + value = typeof value === 'object' ? + JSON.parse(JSON.stringify(value)) : + value + for(let i = 0; i < props.length - 1; i++) { + var prop = props[i] + if(typeof target[prop] === 'undefined') + target[prop] = getWriteContext(props[i + 1]) + target = target[prop] } + target[props[props.length - 1]] = value + } } \ No newline at end of file diff --git a/src/http/normalized.js b/src/http/normalized.js index 261b1ef..330cf47 100644 --- a/src/http/normalized.js +++ b/src/http/normalized.js @@ -4,51 +4,51 @@ import { normaliz } from 'normaliz' import { identity, defaultSerialize, defaultRootKey, normalizedOperations } from './tools.js' export function normalized(url, { - store, - normalize, - client = wretch(), - beforeRequest = identity, - afterRequest = identity, - rootKey = defaultRootKey, - serialize = defaultSerialize, - bodyType = 'json', - policy = 'cache-first' + store, + normalize, + client = wretch(), + beforeRequest = identity, + afterRequest = identity, + rootKey = defaultRootKey, + serialize = defaultSerialize, + bodyType = 'json', + policy = 'cache-first' }) { - const configuredClient = beforeRequest(client.url(url)) - const storeKey = serialize('get', configuredClient._url) - if(!store[rootKey]) { - store[rootKey] = {} - } - const storedMappings = store[rootKey][storeKey] - const cacheLookup = policy !== 'network-only' - const data = + const configuredClient = beforeRequest(client.url(url)) + const storeKey = serialize('get', configuredClient._url) + if(!store[rootKey]) { + store[rootKey] = {} + } + const storedMappings = store[rootKey][storeKey] + const cacheLookup = policy !== 'network-only' + const data = cacheLookup && storedMappings && normalizedOperations.read(storedMappings, store) || null - function refetch() { - return configuredClient - .get() - // eslint-disable-next-line no-unexpected-multiline - [bodyType](body => afterRequest(body)) - .then(result => { - const normalizedData = normaliz(result, normalize) - store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { - mappings[entity] = Object.keys(dataById) - return mappings - }, {}) - normalizedOperations.write(normalizedData, store) - const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) - return storeSlice - }) - } + function refetch() { + return configuredClient + .get() + // eslint-disable-next-line no-unexpected-multiline + [bodyType](body => afterRequest(body)) + .then(result => { + const normalizedData = normaliz(result, normalize) + store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { + mappings[entity] = Object.keys(dataById) + return mappings + }, {}) + normalizedOperations.write(normalizedData, store) + const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) + return storeSlice + }) + } - const future = policy !== 'cache-first' || !data ? refetch() : null + const future = policy !== 'cache-first' || !data ? refetch() : null - return { - data, - refetch, - future - } + return { + data, + refetch, + future + } } diff --git a/src/http/request.js b/src/http/request.js index 0d21a1c..a081206 100644 --- a/src/http/request.js +++ b/src/http/request.js @@ -3,40 +3,40 @@ import wretch from 'wretch' import { identity, defaultSerialize, defaultRootKey } from './tools.js' export function request(url, { - store, - client = wretch(), - beforeRequest = identity, - afterRequest = identity, - rootKey = defaultRootKey, - serialize = defaultSerialize, - bodyType = 'json', - policy = 'cache-first' + store, + client = wretch(), + beforeRequest = identity, + afterRequest = identity, + rootKey = defaultRootKey, + serialize = defaultSerialize, + bodyType = 'json', + policy = 'cache-first' }) { - const configuredClient = beforeRequest(client.url(url)) - const storeKey = serialize('get', configuredClient._url) - if(!store[rootKey]) { - store[rootKey] = {} - } - const storedData = store[rootKey][storeKey] - const cacheLookup = policy !== 'network-only' - const data = cacheLookup && storedData || null + const configuredClient = beforeRequest(client.url(url)) + const storeKey = serialize('get', configuredClient._url) + if(!store[rootKey]) { + store[rootKey] = {} + } + const storedData = store[rootKey][storeKey] + const cacheLookup = policy !== 'network-only' + const data = cacheLookup && storedData || null - function refetch() { - return configuredClient - .get() - // eslint-disable-next-line no-unexpected-multiline - [bodyType](body => afterRequest(body)) - .then(result => { - store[rootKey][storeKey] = result - return result - }) - } + function refetch() { + return configuredClient + .get() + // eslint-disable-next-line no-unexpected-multiline + [bodyType](body => afterRequest(body)) + .then(result => { + store[rootKey][storeKey] = result + return result + }) + } - const future = policy !== 'cache-first' || !data ? refetch() : null + const future = policy !== 'cache-first' || !data ? refetch() : null - return { - data, - refetch, - future - } + return { + data, + refetch, + future + } } diff --git a/src/http/resource.js b/src/http/resource.js index 4beb615..e5c537c 100644 --- a/src/http/resource.js +++ b/src/http/resource.js @@ -1,56 +1,56 @@ import { normalized } from './normalized.js' function formatData(data, entity, id) { - return ( - data ? - id !== null ? - data[entity] && data[entity][id] : - data[entity] && Object.values(data[entity]) : - data - ) + return ( + data ? + id !== null ? + data[entity] && data[entity][id] : + data[entity] && Object.values(data[entity]) : + data + ) } export function resource(entity, url, { - id = null, + id = null, + store, + normalize, + client, + beforeRequest, + afterRequest, + serialize, + rootKey, + bodyType, + policy = 'cache-first' +}) { + const storedEntity = id && store[entity] && store[entity][id] + + const { + data, + future, + refetch: normalizedRefetch + } = normalized(url, { store, - normalize, + normalize: { + schema: [], + ...normalize, + entity + }, client, beforeRequest, afterRequest, serialize, rootKey, bodyType, - policy = 'cache-first' -}) { - const storedEntity = id && store[entity] && store[entity][id] - - const { - data, - future, - refetch: normalizedRefetch - } = normalized(url, { - store, - normalize: { - schema: [], - ...normalize, - entity - }, - client, - beforeRequest, - afterRequest, - serialize, - rootKey, - bodyType, - policy - }) + policy + }) - const refetch = () => normalizedRefetch().then(data => - formatData(data, entity, id) - ) + const refetch = () => normalizedRefetch().then(data => + formatData(data, entity, id) + ) - return { - data: policy !== 'network-only' && storedEntity || formatData(data, entity, id), - future: future && future.then(data => formatData(data, entity, id)) || null, - refetch - } + return { + data: policy !== 'network-only' && storedEntity || formatData(data, entity, id), + future: future && future.then(data => formatData(data, entity, id)) || null, + refetch + } } diff --git a/src/http/tools.js b/src/http/tools.js index e9fa2f6..0c4987e 100644 --- a/src/http/tools.js +++ b/src/http/tools.js @@ -3,35 +3,35 @@ export const defaultSerialize = (method, url) => `${method}@${url}` export const identity = _ => _ export const normalizedOperations = { - read(mappings, store) { - const storeFragment = {} - Object.entries(mappings).forEach(([ entity, ids ]) => { - storeFragment[entity] = {} - ids.forEach(key => { - storeFragment[entity][key] = store[entity] && store[entity][key] || null - }) - }) - return storeFragment - }, - write(normalizedData, store) { - Object.entries(normalizedData).forEach(([ entity, entityData ]) => { - if(!store[entity]) { - store[entity] = {} - } + read(mappings, store) { + const storeFragment = {} + Object.entries(mappings).forEach(([ entity, ids ]) => { + storeFragment[entity] = {} + ids.forEach(key => { + storeFragment[entity][key] = store[entity] && store[entity][key] || null + }) + }) + return storeFragment + }, + write(normalizedData, store) { + Object.entries(normalizedData).forEach(([ entity, entityData ]) => { + if(!store[entity]) { + store[entity] = {} + } - Object.entries(entityData).forEach(([ key, value ]) => { - if(store[entity][key]) { - if(typeof store[entity][key] === 'object' && typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - store[entity][key][k] = v - }) - } else { - store[entity][key] = value - } - } else { - store[entity][key] = value - } + Object.entries(entityData).forEach(([ key, value ]) => { + if(store[entity][key]) { + if(typeof store[entity][key] === 'object' && typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + store[entity][key][k] = v }) - }) - } + } else { + store[entity][key] = value + } + } else { + store[entity][key] = value + } + }) + }) + } } diff --git a/src/index.js b/src/index.js index 590b60d..d086441 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,11 @@ import { observe } from './observe.js' import { computed } from './computed.js' import { dispose } from './dispose.js' +import { process } from './batcher.js' export default { - observe, - computed, - dispose + observe, + computed, + dispose, + batch: process } diff --git a/src/observe.js b/src/observe.js index 128d125..120a066 100644 --- a/src/observe.js +++ b/src/observe.js @@ -1,22 +1,24 @@ import { - isObj, - defineBubblingProperties, - getInstanceMethodKeys, - setHiddenKey + isObj, + defineBubblingProperties, + getInstanceMethodKeys, + setHiddenKey } from './tools.js' import { data } from './data.js' import { enqueue } from './batcher.js' -const { observersMap, computedStack, computedDependenciesTracker } = data +const { computedStack, trackerSymbol } = data + +const observedSymbol = Symbol('__observed') /** * @typedef {Object} Options - Observe options. - * @property {string[]} [prop] - Observe only the properties listed. + * @property {string[]} [props] - Observe only the properties listed. * @property {string[]} [ignore] - Ignore the properties listed. * @property {boolean | number} [batch] - - * Batch computed properties calls, wrapping them in a setTimeout and + * Batch computed properties calls, wrapping them in a queueMicrotask and * executing them in a new context and preventing excessive calls. - * If batch is an integer greater than zero, the calls will be debounced by the value in milliseconds. + * If batch is an integer, the calls will be debounced by the value in milliseconds using setTimemout. * @prop {number} [deep] - Recursively observe nested objects and when setting new properties. * @prop {number} [bind] - Automatically bind methods to the observed object. */ @@ -30,152 +32,155 @@ const { observersMap, computedStack, computedDependenciesTracker } = data * @returns {O} - A proxy wrapping the object. */ export function observe(obj, options = {}) { - // 'deep' is slower but reasonable; 'shallow' a performance enhancement but with side-effects - const { - props, - ignore, - batch, - deep = true, - bubble, - bind - } = options - - // Ignore if the object is already observed - if(obj.__observed) { - return obj - } - - // If the prop is explicitely not excluded - const isWatched = (prop, value) => - ( - !props || + // 'deep' is slower but reasonable; 'shallow' a performance enhancement but with side-effects + const { + props, + ignore, + batch, + deep = true, + bubble, + bind + } = options + + // Ignore if the object is already observed + if(obj[observedSymbol]) { + return obj + } + + // If the prop is explicitely not excluded + const isWatched = (prop, value) => + prop !== observedSymbol && + ( + !props || props instanceof Array && props.includes(prop) || typeof props === 'function' && props(prop, value) - ) && ( - !ignore || + ) && ( + !ignore || !(ignore instanceof Array && ignore.includes(prop)) && !(typeof ignore === 'function' && ignore(prop, value)) - ) - - - // Add the object to the observers map. - // observersMap signature : Map>> - // In other words, observersMap is a map of observed objects. - // For each observed object, each property is mapped with a set of computed functions depending on this property. - // Whenever a property is set, we re-run each one of the functions stored inside the matching Set. - observersMap.set(obj, new Map()) - - // If the deep flag is set, observe nested objects/arrays - if(deep) { - Object.entries(obj).forEach(function([key, val]) { - if(isObj(val) && isWatched(key, val)) { - obj[key] = observe(val, options) - // If bubble is set, we add keys to the object used to bubble up the mutation - if(bubble) { - defineBubblingProperties(obj[key], key, obj) - } + ) + + // If the deep flag is set, observe nested objects/arrays + if(deep) { + Object.entries(obj).forEach(function([key, val]) { + if(isObj(val) && isWatched(key, val)) { + obj[key] = observe(val, options) + // If bubble is set, we add keys to the object used to bubble up the mutation + if(bubble) { + defineBubblingProperties(obj[key], key, obj) + } + } + }) + } + + // For each observed object, each property is mapped with a set of computed functions depending on this property. + // Whenever a property is set, we re-run each one of the functions stored inside the matching Set. + const propertiesMap = new Map() + + // Proxify the object in order to intercept get/set on props + const proxy = new Proxy(obj, { + get(_, prop) { + if(prop === observedSymbol) + return true + + // If the prop is watched + if(isWatched(prop, obj[prop])) { + // If a computed function is being run + if(computedStack.length) { + const computedFn = computedStack[0] + // Tracks object and properties accessed during the function call + const tracker = computedFn[trackerSymbol] + if(tracker) { + let trackerSet = tracker.get(obj) + if(!trackerSet) { + trackerSet = new Set() + tracker.set(obj, trackerSet) } - }) - } + trackerSet.add(prop) + } + // Link the computed function and the property being accessed + let propertiesSet = propertiesMap.get(prop) + if(!propertiesSet) { + propertiesSet = new Set() + propertiesMap.set(prop, propertiesSet) + } + propertiesSet.add(computedFn) + } + } + + return obj[prop] + }, + set(_, prop, value) { + if(prop === '__handler') { + // Don't track bubble handlers + setHiddenKey(obj, '__handler', value) + } else if(!isWatched(prop, value)) { + // If the prop is ignored + obj[prop] = value + } else if(Array.isArray(obj) && prop === 'length' || obj[prop] !== value) { + // If the new/old value are not equal + const deeper = deep && isObj(value) + + // Remove bubbling infrastructure and pass old value to handlers + const oldValue = obj[prop] + if(isObj(oldValue)) + delete obj[prop] + + // If the deep flag is set we observe the newly set value + obj[prop] = deeper ? observe(value, options) : value + + // Co-opt assigned object into bubbling if appropriate + if(deeper && bubble) { + defineBubblingProperties(obj[prop], prop, obj) + } - // Proxify the object in order to intercept get/set on props - const proxy = new Proxy(obj, { - get(_, prop) { - if(prop === '__observed') - return true - - // If the prop is watched - if(isWatched(prop, obj[prop])) { - // If a computed function is being run - if(computedStack.length) { - const propertiesMap = observersMap.get(obj) - if(!propertiesMap.has(prop)) - propertiesMap.set(prop, new Set()) - // Tracks object and properties accessed during the function call - const tracker = computedDependenciesTracker.get(computedStack[0]) - if(tracker) { - if(!tracker.has(obj)) { - tracker.set(obj, new Set()) - } - tracker.get(obj).add(prop) - } - // Link the computed function and the property being accessed - propertiesMap.get(prop).add(computedStack[0]) - } - } + const ancestry = [ prop ] + let parent = obj + while(parent) { + // If a handler explicitly returns 'false' then stop propagation + if(parent.__handler && parent.__handler(ancestry, value, oldValue, proxy) === false) { + break + } + // Continue propagation, traversing the mutated property's object hierarchy & call any __handlers along the way + if(parent.__key && parent.__parent) { + ancestry.unshift(parent.__key) + parent = parent.__parent + } else { + parent = null + } + } - return obj[prop] - }, - set(_, prop, value) { - if(prop === '__handler') { - // Don't track bubble handlers - setHiddenKey(obj, '__handler', value) - } else if(!isWatched(prop, value)) { - // If the prop is ignored - obj[prop] = value - } else if(Array.isArray(obj) && prop === 'length' || obj[prop] !== value) { - // If the new/old value are not equal - const deeper = deep && isObj(value) - const propertiesMap = observersMap.get(obj) - - // Remove bubbling infrastructure and pass old value to handlers - const oldValue = obj[prop] - if(isObj(oldValue)) - delete obj[prop] - - // If the deep flag is set we observe the newly set value - obj[prop] = deeper ? observe(value, options) : value - - // Co-opt assigned object into bubbling if appropriate - if(deeper && bubble) { - defineBubblingProperties(obj[prop], prop, obj) - } - - const ancestry = [ prop ] - let parent = obj - while(parent) { - // If a handler explicitly returns 'false' then stop propagation - if(parent.__handler && parent.__handler(ancestry, value, oldValue, proxy) === false) { - break - } - // Continue propagation, traversing the mutated property's object hierarchy & call any __handlers along the way - if(parent.__key && parent.__parent) { - ancestry.unshift(parent.__key) - parent = parent.__parent - } else { - parent = null - } - } - - const dependents = propertiesMap.get(prop) - if(dependents) { - // Retrieve the computed functions depending on the prop - for(const dependent of dependents) { - const tracker = computedDependenciesTracker.get(dependent) - // If the function has been disposed or if the prop has not been used - // during the latest function call, delete the function reference - if(dependent.__disposed || tracker && (!tracker.has(obj) || !tracker.get(obj).has(prop))) { - dependents.delete(dependent) - } else if(dependent !== computedStack[0]) { - // Run the computed function - if(batch) { - enqueue(dependent, batch) - } else { - dependent() - } - } - } - } + const dependents = propertiesMap.get(prop) + if(dependents) { + // Retrieve the computed functions depending on the prop + for(const dependent of dependents) { + const tracker = dependent[trackerSymbol] + const trackedObj = tracker && tracker.get(obj) + const tracked = trackedObj && trackedObj.has(prop) + // If the function has been disposed or if the prop has not been used + // during the latest function call, delete the function reference + if(dependent.__disposed || tracker && !tracked) { + dependents.delete(dependent) + } else if(dependent !== computedStack[0]) { + // Run the computed function + if(typeof batch !== 'undefined') { + enqueue(dependent, batch) + } else { + dependent() + } } - - return true + } } - }) + } - if(bind) { - // Need this for binding es6 classes methods which are stored in the object prototype - getInstanceMethodKeys(obj).forEach(key => obj[key] = obj[key].bind(proxy)) + return true } + }) + + if(bind) { + // Need this for binding es6 classes methods which are stored in the object prototype + getInstanceMethodKeys(obj).forEach(key => obj[key] = obj[key].bind(proxy)) + } - return proxy + return proxy } diff --git a/src/react/context/index.js b/src/react/context/index.js index b72228a..a82bf60 100644 --- a/src/react/context/index.js +++ b/src/react/context/index.js @@ -2,47 +2,47 @@ import React from 'react' import ReactDOMServer from 'react-dom/server' export const HyperactivContext = React.createContext({ - store: null, - client: null + store: null, + client: null }) export const SSRContext = React.createContext(null) export function HyperactivProvider({ children, store, client }) { - return React.createElement( - HyperactivContext.Provider, - { - value: { store, client } - }, - children - ) + return React.createElement( + HyperactivContext.Provider, + { + value: { store, client } + }, + children + ) } export function SSRProvider({ children, promises }) { - return React.createElement( - SSRContext.Provider, - { - value: promises - }, - children - ) + return React.createElement( + SSRContext.Provider, + { + value: promises + }, + children + ) } export async function preloadData(jsx, { depth = null } = {}) { - let loopIterations = 0 - const promises = [] - while(loopIterations === 0 || promises.length > 0) { - if(depth !== null && loopIterations >= depth) - break - promises.length = 0 - ReactDOMServer.renderToStaticMarkup( - React.createElement( - SSRProvider, - { promises }, - jsx - ) - ) - await Promise.all(promises) - loopIterations++ - } + let loopIterations = 0 + const promises = [] + while(loopIterations === 0 || promises.length > 0) { + if(depth !== null && loopIterations >= depth) + break + promises.length = 0 + ReactDOMServer.renderToStaticMarkup( + React.createElement( + SSRProvider, + { promises }, + jsx + ) + ) + await Promise.all(promises) + loopIterations++ + } } diff --git a/src/react/hooks/context.js b/src/react/hooks/context.js index ddbde10..62abbe4 100644 --- a/src/react/hooks/context.js +++ b/src/react/hooks/context.js @@ -2,11 +2,11 @@ import { useContext } from 'react' import { HyperactivContext } from '../context/index.js' export function useStore() { - const context = useContext(HyperactivContext) - return context && context.store + const context = useContext(HyperactivContext) + return context && context.store } export function useClient() { - const context = useContext(HyperactivContext) - return context && context.client + const context = useContext(HyperactivContext) + return context && context.client } diff --git a/src/react/hooks/dependencies.js b/src/react/hooks/dependencies.js index 7b240a4..0849456 100644 --- a/src/react/hooks/dependencies.js +++ b/src/react/hooks/dependencies.js @@ -1,12 +1,12 @@ export default new Proxy({ - references: { - wretch: null, - normaliz: null - } + references: { + wretch: null, + normaliz: null + } }, { - get(target, property) { - if(target[property]) - return target[property] - throw 'Hook dependencies are not registered!\nUse `.setHooksDependencies({ wretch, normaliz }) to set them.' - } + get(target, property) { + if(target[property]) + return target[property] + throw 'Hook dependencies are not registered!\nUse `.setHooksDependencies({ wretch, normaliz }) to set them.' + } }) \ No newline at end of file diff --git a/src/react/hooks/index.js b/src/react/hooks/index.js index 48dd05b..49b99cb 100644 --- a/src/react/hooks/index.js +++ b/src/react/hooks/index.js @@ -1,8 +1,8 @@ import dependencies from './dependencies.js' export function setHooksDependencies({ wretch, normaliz }) { - if(wretch) dependencies.references.wretch = wretch - if(normaliz) dependencies.references.normaliz = normaliz + if(wretch) dependencies.references.wretch = wretch + if(normaliz) dependencies.references.normaliz = normaliz } export * from './useNormalizedRequest.js' diff --git a/src/react/hooks/useNormalizedRequest.js b/src/react/hooks/useNormalizedRequest.js index af068b9..73ba5ca 100644 --- a/src/react/hooks/useNormalizedRequest.js +++ b/src/react/hooks/useNormalizedRequest.js @@ -5,117 +5,117 @@ import { HyperactivContext, SSRContext } from '../context/index.js' import dependencies from './dependencies.js' export function useNormalizedRequest(url, { - store, - normalize, - client, - skip = () => false, - beforeRequest = identity, - afterRequest = identity, - rootKey = defaultRootKey, - serialize = defaultSerialize, - bodyType = 'json', - policy = 'cache-first', - ssr = true + store, + normalize, + client, + skip = () => false, + beforeRequest = identity, + afterRequest = identity, + rootKey = defaultRootKey, + serialize = defaultSerialize, + bodyType = 'json', + policy = 'cache-first', + ssr = true }) { - const contextValue = useContext(HyperactivContext) - const ssrContext = ssr && useContext(SSRContext) - store = contextValue && contextValue.store || store - client = contextValue && contextValue.client || client || dependencies.references.wretch() + const contextValue = useContext(HyperactivContext) + const ssrContext = ssr && useContext(SSRContext) + store = contextValue && contextValue.store || store + client = contextValue && contextValue.client || client || dependencies.references.wretch() - const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) - const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) - if(!store[rootKey]) { - store[rootKey] = {} - } - const storedMappings = store[rootKey][storeKey] + const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) + const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) + if(!store[rootKey]) { + store[rootKey] = {} + } + const storedMappings = store[rootKey][storeKey] - const cacheLookup = policy !== 'network-only' + const cacheLookup = policy !== 'network-only' - const [ error, setError ] = useState(null) - const [ loading, setLoading ] = useState( - !cacheLookup || + const [ error, setError ] = useState(null) + const [ loading, setLoading ] = useState( + !cacheLookup || !storedMappings - ) - const [ networkData, setNetworkData ] = useState(null) - const data = + ) + const [ networkData, setNetworkData ] = useState(null) + const data = cacheLookup ? - storedMappings && + storedMappings && normalizedOperations.read(storedMappings, store) : - networkData + networkData - const unmounted = useRef(false) - useEffect(() => () => unmounted.current = false, []) - const pendingRequests = useRef([]) + const unmounted = useRef(false) + useEffect(() => () => unmounted.current = false, []) + const pendingRequests = useRef([]) - function refetch(noState) { - if(!noState && !unmounted.current) { - setLoading(true) - setError(null) - setNetworkData(null) + function refetch(noState) { + if(!noState && !unmounted.current) { + setLoading(true) + setError(null) + setNetworkData(null) + } + const promise = configuredClient + .get() + // eslint-disable-next-line no-unexpected-multiline + [bodyType](body => afterRequest(body)) + .then(result => { + const normalizedData = dependencies.references.normaliz(result, normalize) + store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { + mappings[entity] = Object.keys(dataById) + return mappings + }, {}) + normalizedOperations.write(normalizedData, store) + const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) + pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) + if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { + setNetworkData(storeSlice) + setLoading(false) } - const promise = configuredClient - .get() - // eslint-disable-next-line no-unexpected-multiline - [bodyType](body => afterRequest(body)) - .then(result => { - const normalizedData = dependencies.references.normaliz(result, normalize) - store[rootKey][storeKey] = Object.entries(normalizedData).reduce((mappings, [ entity, dataById ]) => { - mappings[entity] = Object.keys(dataById) - return mappings - }, {}) - normalizedOperations.write(normalizedData, store) - const storeSlice = normalizedOperations.read(store[rootKey][storeKey], store) - pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) - if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { - setNetworkData(storeSlice) - setLoading(false) - } - return storeSlice - }) - .catch(error => { - pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) - if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { - setError(error) - setLoading(false) - } - if(ssrContext) - throw error - }) - - pendingRequests.current.push(promise) - if(ssrContext) { - ssrContext.push(promise) + return storeSlice + }) + .catch(error => { + pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) + if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { + setError(error) + setLoading(false) } - return promise + if(ssrContext) + throw error + }) + + pendingRequests.current.push(promise) + if(ssrContext) { + ssrContext.push(promise) } + return promise + } - function checkAndRefetch(noState = false) { - if( - !skip() && + function checkAndRefetch(noState = false) { + if( + !skip() && !error && (policy !== 'cache-first' || !data) - ) { - refetch(noState) - } + ) { + refetch(noState) } + } - useEffect(function() { - checkAndRefetch() - }, [ storeKey, skip() ]) + useEffect(function() { + checkAndRefetch() + }, [ storeKey, skip() ]) - if(ssrContext) { - checkAndRefetch(true) - } + if(ssrContext) { + checkAndRefetch(true) + } - return skip() ? { - data: null, - error: null, - loading: false, - refetch - } : { - loading, - data, - error, - refetch - } + return skip() ? { + data: null, + error: null, + loading: false, + refetch + } : { + loading, + data, + error, + refetch + } } diff --git a/src/react/hooks/useRequest.js b/src/react/hooks/useRequest.js index a88f941..244a718 100644 --- a/src/react/hooks/useRequest.js +++ b/src/react/hooks/useRequest.js @@ -4,106 +4,106 @@ import { HyperactivContext, SSRContext } from '../context/index.js' import dependencies from './dependencies.js' export function useRequest(url, { - store, - client, - skip = () => false, - beforeRequest = identity, - afterRequest = identity, - rootKey = defaultRootKey, - serialize = defaultSerialize, - bodyType = 'json', - policy = 'cache-first', - ssr = true + store, + client, + skip = () => false, + beforeRequest = identity, + afterRequest = identity, + rootKey = defaultRootKey, + serialize = defaultSerialize, + bodyType = 'json', + policy = 'cache-first', + ssr = true }) { - const contextValue = useContext(HyperactivContext) - const ssrContext = ssr && useContext(SSRContext) - store = contextValue && contextValue.store || store - client = contextValue && contextValue.client || client || dependencies.references.wretch() + const contextValue = useContext(HyperactivContext) + const ssrContext = ssr && useContext(SSRContext) + store = contextValue && contextValue.store || store + client = contextValue && contextValue.client || client || dependencies.references.wretch() - const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) - const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) - if(!store[rootKey]) { - store[rootKey] = {} - } - const storedData = store[rootKey][storeKey] + const configuredClient = useMemo(() => beforeRequest(client.url(url)), [client, beforeRequest, url]) + const storeKey = useMemo(() => serialize('get', configuredClient._url), [configuredClient]) + if(!store[rootKey]) { + store[rootKey] = {} + } + const storedData = store[rootKey][storeKey] - const cacheLookup = policy !== 'network-only' + const cacheLookup = policy !== 'network-only' - const [ error, setError ] = useState(null) - const [ loading, setLoading ] = useState( - !cacheLookup || + const [ error, setError ] = useState(null) + const [ loading, setLoading ] = useState( + !cacheLookup || !storedData - ) - const [ networkData, setNetworkData ] = useState(null) - const data = cacheLookup ? storedData : networkData + ) + const [ networkData, setNetworkData ] = useState(null) + const data = cacheLookup ? storedData : networkData - const unmounted = useRef(false) - useEffect(() => () => unmounted.current = false, []) - const pendingRequests = useRef([]) + const unmounted = useRef(false) + useEffect(() => () => unmounted.current = false, []) + const pendingRequests = useRef([]) - function refetch(noState) { - if(!noState && !unmounted.current) { - setLoading(true) - setError(null) - setNetworkData(null) + function refetch(noState) { + if(!noState && !unmounted.current) { + setLoading(true) + setError(null) + setNetworkData(null) + } + const promise = configuredClient + .get() + // eslint-disable-next-line no-unexpected-multiline + [bodyType](body => afterRequest(body)) + .then(result => { + store[rootKey][storeKey] = result + pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) + if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { + setNetworkData(result) + setLoading(false) } - const promise = configuredClient - .get() - // eslint-disable-next-line no-unexpected-multiline - [bodyType](body => afterRequest(body)) - .then(result => { - store[rootKey][storeKey] = result - pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) - if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { - setNetworkData(result) - setLoading(false) - } - return result - }) - .catch(error => { - pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) - if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { - setError(error) - setLoading(false) - } - if(ssrContext) - throw error - }) - - pendingRequests.current.push(promise) - if(ssrContext) { - ssrContext.push(promise) + return result + }) + .catch(error => { + pendingRequests.current.splice(pendingRequests.current.indexOf(promise), 1) + if(!ssrContext && !unmounted.current && pendingRequests.current.length === 0) { + setError(error) + setLoading(false) } - return promise + if(ssrContext) + throw error + }) + + pendingRequests.current.push(promise) + if(ssrContext) { + ssrContext.push(promise) } + return promise + } - function checkAndRefetch(noState = false) { - if( - !skip() && + function checkAndRefetch(noState = false) { + if( + !skip() && !error && (policy !== 'cache-first' || !data) - ) { - refetch(noState) - } + ) { + refetch(noState) } + } - useEffect(function() { - checkAndRefetch() - }, [ storeKey, skip() ]) + useEffect(function() { + checkAndRefetch() + }, [ storeKey, skip() ]) - if(ssrContext) { - checkAndRefetch(true) - } + if(ssrContext) { + checkAndRefetch(true) + } - return skip() ? { - data: null, - error: null, - loading: false, - refetch - } : { - loading, - data, - error, - refetch - } + return skip() ? { + data: null, + error: null, + loading: false, + refetch + } : { + loading, + data, + error, + refetch + } } diff --git a/src/react/hooks/useResource.js b/src/react/hooks/useResource.js index a9937b9..f6ead19 100644 --- a/src/react/hooks/useResource.js +++ b/src/react/hooks/useResource.js @@ -3,82 +3,82 @@ import { useNormalizedRequest } from './useNormalizedRequest.js' import { HyperactivContext } from '../context/index.js' function formatData(data, entity, id) { - return ( - data ? - id !== null ? - data[entity] && data[entity][id] : - data[entity] && Object.values(data[entity]) : - data - ) + return ( + data ? + id !== null ? + data[entity] && data[entity][id] : + data[entity] && Object.values(data[entity]) : + data + ) } export function useResource(entity, url, { - id = null, + id = null, + store, + normalize, + client, + skip: skipProp = () => false, + beforeRequest, + afterRequest, + serialize, + rootKey, + bodyType, + policy = 'cache-first', + ssr = true +}) { + const contextValue = useContext(HyperactivContext) + store = contextValue && contextValue.store || store + const storedEntity = id && store[entity] && store[entity][id] + + const { + data, + loading, + error, + refetch: normalizedRefetch + } = useNormalizedRequest(url, { store, - normalize, + normalize: { + schema: [], + ...normalize, + entity + }, client, - skip: skipProp = () => false, + skip() { + return ( + policy === 'cache-first' && storedEntity || + skipProp() + ) + }, beforeRequest, afterRequest, serialize, rootKey, bodyType, - policy = 'cache-first', - ssr = true -}) { - const contextValue = useContext(HyperactivContext) - store = contextValue && contextValue.store || store - const storedEntity = id && store[entity] && store[entity][id] + policy, + ssr + }) - const { - data, - loading, - error, - refetch: normalizedRefetch - } = useNormalizedRequest(url, { - store, - normalize: { - schema: [], - ...normalize, - entity - }, - client, - skip() { - return ( - policy === 'cache-first' && storedEntity || - skipProp() - ) - }, - beforeRequest, - afterRequest, - serialize, - rootKey, - bodyType, - policy, - ssr - }) + const formattedData = useMemo(() => + formatData(data, entity, id) + , [data, entity, id]) - const formattedData = useMemo(() => - formatData(data, entity, id) - , [data, entity, id]) - - const refetch = () => normalizedRefetch().then(data => - formatData(data, entity, id) - ) - - if(policy !== 'network-only' && storedEntity) { - return { - data: storedEntity, - loading: false, - error: null, - refetch - } - } + const refetch = () => normalizedRefetch().then(data => + formatData(data, entity, id) + ) + if(policy !== 'network-only' && storedEntity) { return { - data: formattedData, - loading, - error, - refetch + data: storedEntity, + loading: false, + error: null, + refetch } + } + + return { + data: formattedData, + loading, + error, + refetch + } } diff --git a/src/react/index.js b/src/react/index.js index 63d28cd..6086a8f 100644 --- a/src/react/index.js +++ b/src/react/index.js @@ -6,5 +6,5 @@ export * from './hooks/index.js' export * from './context/index.js' export const store = function(obj, options = {}) { - return hyperactiv.observe(obj, Object.assign({ deep: true, batch: false }, options)) + return hyperactiv.observe(obj, Object.assign({ deep: true, batch: false }, options)) } diff --git a/src/react/watchComponent.js b/src/react/watchComponent.js index c5600b8..be6da12 100644 --- a/src/react/watchComponent.js +++ b/src/react/watchComponent.js @@ -3,32 +3,32 @@ import hyperactiv from '../../src/index.js' const { computed, dispose } = hyperactiv export class Watch extends React.Component { - constructor(props) { - super(props) - this._callback = () => { - this._mounted && + constructor(props) { + super(props) + this._callback = () => { + this._mounted && this.forceUpdate.bind(this)() - } - this.computeRenderMethod(props.render) } - componentWillUnmount() { - this._mounted = false - dispose(this._callback) - } - componentDidMount() { - this._mounted = true - } - computeRenderMethod(newRender) { - if(!!newRender && this._currentRender !== newRender) { - this._currentRender = computed(newRender, { - autoRun: false, - callback: this._callback - }) - } - } - render() { - const { render } = this.props - this.computeRenderMethod(render) - return this._currentRender && this._currentRender() || null + this.computeRenderMethod(props.render) + } + componentWillUnmount() { + this._mounted = false + dispose(this._callback) + } + componentDidMount() { + this._mounted = true + } + computeRenderMethod(newRender) { + if(!!newRender && this._currentRender !== newRender) { + this._currentRender = computed(newRender, { + autoRun: false, + callback: this._callback + }) } + } + render() { + const { render } = this.props + this.computeRenderMethod(render) + return this._currentRender && this._currentRender() || null + } } diff --git a/src/react/watchHoc.js b/src/react/watchHoc.js index 1195dcf..d705cef 100644 --- a/src/react/watchHoc.js +++ b/src/react/watchHoc.js @@ -8,32 +8,32 @@ const { computed, dispose } = hyperactiv * @param {*} Component The component to wrap */ const watchClassComponent = Component => new Proxy(Component, { - construct: function(Target, argumentsList) { - // Create a new Component instance - const instance = new Target(...argumentsList) - // Ensures that the forceUpdate in correctly bound - instance.forceUpdate = instance.forceUpdate.bind(instance) - // Monkey patch the componentWillUnmount method to do some clean up on destruction - const originalUnmount = + construct: function(Target, argumentsList) { + // Create a new Component instance + const instance = new Target(...argumentsList) + // Ensures that the forceUpdate in correctly bound + instance.forceUpdate = instance.forceUpdate.bind(instance) + // Monkey patch the componentWillUnmount method to do some clean up on destruction + const originalUnmount = typeof instance.componentWillUnmount === 'function' && instance.componentWillUnmount.bind(instance) - instance.componentWillUnmount = function(...args) { - dispose(instance.forceUpdate) - if(originalUnmount) { - originalUnmount(...args) - } - } - // Return a proxified Component - return new Proxy(instance, { - get: function(target, property) { - if(property === 'render') { - // Compute the render function and forceUpdate on changes - return computed(target.render.bind(target), { autoRun: false, callback: instance.forceUpdate }) - } - return target[property] - } - }) + instance.componentWillUnmount = function(...args) { + dispose(instance.forceUpdate) + if(originalUnmount) { + originalUnmount(...args) + } } + // Return a proxified Component + return new Proxy(instance, { + get: function(target, property) { + if(property === 'render') { + // Compute the render function and forceUpdate on changes + return computed(target.render.bind(target), { autoRun: false, callback: instance.forceUpdate }) + } + return target[property] + } + }) + } }) /** @@ -41,27 +41,27 @@ const watchClassComponent = Component => new Proxy(Component, { * @param {*} component The component to wrap */ function watchFunctionalComponent(component) { - const wrapper = props => { - const [, forceUpdate] = React.useReducer(x => x + 1, 0) - const store = useStore() - const injectedProps = props.store ? props : { - ...props, - store - } - const [child, setChild] = React.useState(null) - React.useEffect(function onMount() { - setChild(() => computed(component, { - autoRun: false, - callback: forceUpdate - })) - return function onUnmount() { - dispose(forceUpdate) - } - }, []) - return child ? child(injectedProps) : component(injectedProps) + const wrapper = props => { + const [, forceUpdate] = React.useReducer(x => x + 1, 0) + const store = useStore() + const injectedProps = props.store ? props : { + ...props, + store } - wrapper.displayName = component.displayName || component.name - return wrapper + const [child, setChild] = React.useState(null) + React.useEffect(function onMount() { + setChild(() => computed(component, { + autoRun: false, + callback: forceUpdate + })) + return function onUnmount() { + dispose(forceUpdate) + } + }, []) + return child ? child(injectedProps) : component(injectedProps) + } + wrapper.displayName = component.displayName || component.name + return wrapper } /** @@ -69,7 +69,7 @@ function watchFunctionalComponent(component) { * @param {*} Component The component to wrap */ export const watch = Component => - typeof Component === 'function' && + typeof Component === 'function' && (!Component.prototype || !Component.prototype.isReactComponent) ? - watchFunctionalComponent(Component) : - watchClassComponent(Component) + watchFunctionalComponent(Component) : + watchClassComponent(Component) diff --git a/src/tools.js b/src/tools.js index a27be8b..d77090b 100644 --- a/src/tools.js +++ b/src/tools.js @@ -1,30 +1,30 @@ const BIND_IGNORED = [ - 'String', - 'Number', - 'Object', - 'Array', - 'Boolean', - 'Date' + 'String', + 'Number', + 'Object', + 'Array', + 'Boolean', + 'Date' ] export function isObj(object) { return object && typeof object === 'object' } export function setHiddenKey(object, key, value) { - Object.defineProperty(object, key, { value, enumerable: false, configurable: true }) + Object.defineProperty(object, key, { value, enumerable: false, configurable: true }) } export function defineBubblingProperties(object, key, parent) { - setHiddenKey(object, '__key', key) - setHiddenKey(object, '__parent', parent) + setHiddenKey(object, '__key', key) + setHiddenKey(object, '__parent', parent) } export function getInstanceMethodKeys(object) { - return ( - Object - .getOwnPropertyNames(object) - .concat( - Object.getPrototypeOf(object) && + return ( + Object + .getOwnPropertyNames(object) + .concat( + Object.getPrototypeOf(object) && BIND_IGNORED.indexOf(Object.getPrototypeOf(object).constructor.name) < 0 ? - Object.getOwnPropertyNames(Object.getPrototypeOf(object)) : - [] - ) - .filter(prop => prop !== 'constructor' && typeof object[prop] === 'function') - ) + Object.getOwnPropertyNames(Object.getPrototypeOf(object)) : + [] + ) + .filter(prop => prop !== 'constructor' && typeof object[prop] === 'function') + ) } diff --git a/src/websocket/browser.js b/src/websocket/browser.js index b8846ae..57b87d9 100644 --- a/src/websocket/browser.js +++ b/src/websocket/browser.js @@ -1,37 +1,37 @@ import { writeHandler } from '../handlers/write.js' export default (url, obj, debug, timeout) => { - const cbs = {}, ws = new WebSocket(url || 'ws://localhost:8080'), update = writeHandler(obj) - let id = 0 - ws.addEventListener('message', msg => { - msg = JSON.parse(msg.data) - if(debug) - debug(msg) - if(msg.type === 'sync') { - Object.assign(obj, msg.state) - if(Array.isArray(msg.methods)) { - msg.methods.forEach(keys => update(keys, async (...args) => { - ws.send(JSON.stringify({ type: 'call', keys: keys, args: args, request: ++id })) - return new Promise((resolve, reject) => { - cbs[id] = { resolve, reject } - setTimeout(() => { - delete cbs[id] - reject(new Error('Timeout on call to ' + keys)) - }, timeout || 15000) - }) - })) - } - } else if(msg.type === 'update') { - update(msg.keys, msg.value) - } else if(msg.type === 'response') { - if(msg.error) { - cbs[msg.request].reject(msg.error) - } else { - cbs[msg.request].resolve(msg.result) - } - delete cbs[msg.request] - } - }) + const cbs = {}, ws = new WebSocket(url || 'ws://localhost:8080'), update = writeHandler(obj) + let id = 0 + ws.addEventListener('message', msg => { + msg = JSON.parse(msg.data) + if(debug) + debug(msg) + if(msg.type === 'sync') { + Object.assign(obj, msg.state) + if(Array.isArray(msg.methods)) { + msg.methods.forEach(keys => update(keys, async (...args) => { + ws.send(JSON.stringify({ type: 'call', keys: keys, args: args, request: ++id })) + return new Promise((resolve, reject) => { + cbs[id] = { resolve, reject } + setTimeout(() => { + delete cbs[id] + reject(new Error('Timeout on call to ' + keys)) + }, timeout || 15000) + }) + })) + } + } else if(msg.type === 'update') { + update(msg.keys, msg.value) + } else if(msg.type === 'response') { + if(msg.error) { + cbs[msg.request].reject(msg.error) + } else { + cbs[msg.request].resolve(msg.result) + } + delete cbs[msg.request] + } + }) - ws.addEventListener('open', () => ws.send('sync')) + ws.addEventListener('open', () => ws.send('sync')) } \ No newline at end of file diff --git a/src/websocket/server.js b/src/websocket/server.js index 0e8c9e6..5fc4513 100644 --- a/src/websocket/server.js +++ b/src/websocket/server.js @@ -4,106 +4,106 @@ import handlers from '../handlers/index.js' const { observe } = hyperactiv function send(socket, obj) { - socket.send(JSON.stringify(obj)) + socket.send(JSON.stringify(obj)) } function findRemoteMethods({ target, autoExportMethods, stack = [], methods = [] }) { - if(typeof target === 'object') { - if(autoExportMethods) { - Object.entries(target).forEach(([key, value]) => { - if(typeof value === 'function') { - stack.push(key) - methods.push(stack.slice(0)) - stack.pop() - } - - }) - } else if(target.__remoteMethods) { - if(!Array.isArray(target.__remoteMethods)) - target.__remoteMethods = [ target.__remoteMethods ] - target.__remoteMethods.forEach(method => { - stack.push(method) - methods.push(stack.slice(0)) - stack.pop() - }) + if(typeof target === 'object') { + if(autoExportMethods) { + Object.entries(target).forEach(([key, value]) => { + if(typeof value === 'function') { + stack.push(key) + methods.push(stack.slice(0)) + stack.pop() } - Object.keys(target).forEach(key => { - stack.push(key) - findRemoteMethods({ target: target[key], autoExportMethods, stack, methods }) - stack.pop() - }) + }) + } else if(target.__remoteMethods) { + if(!Array.isArray(target.__remoteMethods)) + target.__remoteMethods = [ target.__remoteMethods ] + target.__remoteMethods.forEach(method => { + stack.push(method) + methods.push(stack.slice(0)) + stack.pop() + }) } - return methods + Object.keys(target).forEach(key => { + stack.push(key) + findRemoteMethods({ target: target[key], autoExportMethods, stack, methods }) + stack.pop() + }) + } + + return methods } function server(wss) { - wss.host = (data, options) => { - options = Object.assign({}, { deep: true, batch: true, bubble: true }, options || {}) - const autoExportMethods = options.autoExportMethods - const obj = observe(data || {}, options) - obj.__handler = (keys, value, old) => { - wss.clients.forEach(client => { - if(client.readyState === 1) { - send(client, { type: 'update', keys: keys, value: value, old: old }) - } - }) + wss.host = (data, options) => { + options = Object.assign({}, { deep: true, batch: true, bubble: true }, options || {}) + const autoExportMethods = options.autoExportMethods + const obj = observe(data || {}, options) + obj.__handler = (keys, value, old) => { + wss.clients.forEach(client => { + if(client.readyState === 1) { + send(client, { type: 'update', keys: keys, value: value, old: old }) } - - wss.on('connection', socket => { - socket.on('message', async message => { - if(message === 'sync') { - send(socket, { type: 'sync', state: obj, methods: findRemoteMethods({ target: obj, autoExportMethods }) }) - } else { - message = JSON.parse(message) - // if(message.type && message.type === 'call') { - let cxt = obj, result = null, error = null - message.keys.forEach(key => cxt = cxt[key]) - try { - result = await cxt(...message.args) - } catch(err) { - error = err.message - } - send(socket, { type: 'response', result, error, request: message.request }) - // } - } - }) - }) - return obj + }) } - return wss -} -function client(ws, obj = {}) { - let id = 1 - const cbs = {} - ws.on('message', msg => { - msg = JSON.parse(msg) - if(msg.type === 'sync') { - Object.assign(obj, msg.state) - msg.methods.forEach(keys => handlers.write(obj)(keys, async (...args) => - new Promise((resolve, reject) => { - cbs[id] = { resolve, reject } - send(ws, { type: 'call', keys: keys, args: args, request: id++ }) - }) - )) - } else if(msg.type === 'update') { - handlers.write(obj)(msg.keys, msg.value) - } else /* if(msg.type === 'response') */{ - if(msg.error) { - cbs[msg.request].reject(msg.error) - } else { - cbs[msg.request].resolve(msg.result) - } - delete cbs[msg.request] + wss.on('connection', socket => { + socket.on('message', async message => { + if(message === 'sync') { + send(socket, { type: 'sync', state: obj, methods: findRemoteMethods({ target: obj, autoExportMethods }) }) + } else { + message = JSON.parse(message) + // if(message.type && message.type === 'call') { + let cxt = obj, result = null, error = null + message.keys.forEach(key => cxt = cxt[key]) + try { + result = await cxt(...message.args) + } catch(err) { + error = err.message + } + send(socket, { type: 'response', result, error, request: message.request }) + // } } + }) }) - ws.on('open', () => ws.send('sync')) return obj + } + return wss +} + +function client(ws, obj = {}) { + let id = 1 + const cbs = {} + ws.on('message', msg => { + msg = JSON.parse(msg) + if(msg.type === 'sync') { + Object.assign(obj, msg.state) + msg.methods.forEach(keys => handlers.write(obj)(keys, async (...args) => + new Promise((resolve, reject) => { + cbs[id] = { resolve, reject } + send(ws, { type: 'call', keys: keys, args: args, request: id++ }) + }) + )) + } else if(msg.type === 'update') { + handlers.write(obj)(msg.keys, msg.value) + } else /* if(msg.type === 'response') */{ + if(msg.error) { + cbs[msg.request].reject(msg.error) + } else { + cbs[msg.request].resolve(msg.result) + } + delete cbs[msg.request] + } + }) + ws.on('open', () => ws.send('sync')) + return obj } export default { - server, - client + server, + client } \ No newline at end of file diff --git a/test/classes.test.js b/test/classes.test.js index 12af3e4..2e1c672 100644 --- a/test/classes.test.js +++ b/test/classes.test.js @@ -2,61 +2,61 @@ const classes = require('../src/classes') const { Observable } = classes test('onChange should catch mutation', done => { - const o = new Observable() - o.a = { b: 1 } - o.onChange((keys, value, old, obj) => { - expect(keys).toStrictEqual(['a', 'b']) - expect(value).toBe(2) - expect(old).toBe(1) - expect(obj).toStrictEqual({ b: 2 }) - done() - }) - o.a.b = 2 + const o = new Observable() + o.a = { b: 1 } + o.onChange((keys, value, old, obj) => { + expect(keys).toStrictEqual(['a', 'b']) + expect(value).toBe(2) + expect(old).toBe(1) + expect(obj).toStrictEqual({ b: 2 }) + done() + }) + o.a.b = 2 }) test('computed should register a computed function', () => { - const o = new Observable({ - a: 1, - b: 2, - sum: 0 - }) - o.computed(function() { this.sum = this.a + this.b }) - expect(o.sum).toBe(3) - o.a = 10 - expect(o.sum).toBe(12) + const o = new Observable({ + a: 1, + b: 2, + sum: 0 + }) + o.computed(function() { this.sum = this.a + this.b }) + expect(o.sum).toBe(3) + o.a = 10 + expect(o.sum).toBe(12) }) test('dispose should unregister computed functions', () => { - const o = new Observable({ - a: 1, - b: 2, - sum: 0 - }) - o.computed(function() { this.sum = this.a + this.b }) - expect(o.sum).toBe(3) - o.dispose() - o.a = 10 - expect(o.sum).toBe(3) + const o = new Observable({ + a: 1, + b: 2, + sum: 0 + }) + o.computed(function() { this.sum = this.a + this.b }) + expect(o.sum).toBe(3) + o.dispose() + o.a = 10 + expect(o.sum).toBe(3) }) test('class inheritance', () => { - const ExtendedClass = class extends Observable { - constructor() { - super({ - a: 1, - b: 1 - }) - this.c = 1 - } + const ExtendedClass = class extends Observable { + constructor() { + super({ + a: 1, + b: 1 + }) + this.c = 1 } + } - const instance = new ExtendedClass() - instance.computed(function() { - this.sum = this.a + this.b + this.c - }) - expect(instance.sum).toBe(3) - instance.a = 2 - expect(instance.sum).toBe(4) - instance.c = 2 - expect(instance.sum).toBe(5) + const instance = new ExtendedClass() + instance.computed(function() { + this.sum = this.a + this.b + this.c + }) + expect(instance.sum).toBe(3) + instance.a = 2 + expect(instance.sum).toBe(4) + instance.c = 2 + expect(instance.sum).toBe(5) }) diff --git a/test/environment.js b/test/environment.js index 32a5354..4785acb 100644 --- a/test/environment.js +++ b/test/environment.js @@ -2,11 +2,11 @@ import { TestEnvironment } from 'jest-environment-jsdom' import { TextEncoder, TextDecoder } from 'util' export default class CustomTestEnvironment extends TestEnvironment { - async setup() { - await super.setup() - if(typeof this.global.TextEncoder === 'undefined') { - this.global.TextEncoder = TextEncoder - this.global.TextDecoder = TextDecoder - } + async setup() { + await super.setup() + if(typeof this.global.TextEncoder === 'undefined') { + this.global.TextEncoder = TextEncoder + this.global.TextDecoder = TextDecoder } + } } \ No newline at end of file diff --git a/test/handlers.test.js b/test/handlers.test.js index 6cd3bd2..e94581c 100644 --- a/test/handlers.test.js +++ b/test/handlers.test.js @@ -5,141 +5,141 @@ const { all, write, debug } = handlers test('write handler should proxify mutations to another object', () => { - const copy = {} - const obj = observe({}, { bubble: true, deep: false }) - obj.__handler = write(copy) - obj.a = 10 - expect(copy.a).toBe(10) - obj.b = { c: { d: 15 } } - expect(copy.b.c.d).toBe(15) - obj.b.c.d = 10 - expect(copy.b.c.d).toBe(15) + const copy = {} + const obj = observe({}, { bubble: true, deep: false }) + obj.__handler = write(copy) + obj.a = 10 + expect(copy.a).toBe(10) + obj.b = { c: { d: 15 } } + expect(copy.b.c.d).toBe(15) + obj.b.c.d = 10 + expect(copy.b.c.d).toBe(15) - const copy2 = {} - const obj2 = observe({}, { bubble: true, deep: true }) - obj2.__handler = write(copy2) - obj2.a = 10 - expect(copy2.a).toBe(10) - obj2.b = { c: { d: 15 } } - expect(copy2.b.c.d).toBe(15) - obj2.b.c.d = 10 - expect(copy2.b.c.d).toBe(10) + const copy2 = {} + const obj2 = observe({}, { bubble: true, deep: true }) + obj2.__handler = write(copy2) + obj2.a = 10 + expect(copy2.a).toBe(10) + obj2.b = { c: { d: 15 } } + expect(copy2.b.c.d).toBe(15) + obj2.b.c.d = 10 + expect(copy2.b.c.d).toBe(10) - const copy3 = [] - const obj3 = observe([], { bubble: true, deep: true }) - obj3.__handler = write(copy3) - obj3.push('test') - expect(copy3[0]).toBe('test') - obj3.push({ a: { b: [ { c: 1 }]}}) - expect(copy3[1]).toEqual({ a: { b: [ { c: 1 }]}}) - obj3[1].a.b[0].c = 2 - expect(copy3[1]).toEqual({ a: { b: [ { c: 2 }]}}) + const copy3 = [] + const obj3 = observe([], { bubble: true, deep: true }) + obj3.__handler = write(copy3) + obj3.push('test') + expect(copy3[0]).toBe('test') + obj3.push({ a: { b: [ { c: 1 }]}}) + expect(copy3[1]).toEqual({ a: { b: [ { c: 1 }]}}) + obj3[1].a.b[0].c = 2 + expect(copy3[1]).toEqual({ a: { b: [ { c: 2 }]}}) - const copy4 = {} - const obj4 = observe({ a: { b: 1 }}, { bubble: true, deep: true }) - obj4.__handler = write(copy4) - obj4.a.b = 2 - expect(copy4.a.b).toEqual(2) + const copy4 = {} + const obj4 = observe({ a: { b: 1 }}, { bubble: true, deep: true }) + obj4.__handler = write(copy4) + obj4.a.b = 2 + expect(copy4.a.b).toEqual(2) - const copy5 = [] - const obj5 = observe([[[1]]], { bubble: true, deep: true }) - obj5.__handler = write(copy5) - obj5[0][0][0] = 2 - expect(copy5[0][0][0]).toEqual(2) + const copy5 = [] + const obj5 = observe([[[1]]], { bubble: true, deep: true }) + obj5.__handler = write(copy5) + obj5[0][0][0] = 2 + expect(copy5[0][0][0]).toEqual(2) - expect(() => write()).toThrow() + expect(() => write()).toThrow() - // Improves coverage - delete obj2.b.c + // Improves coverage + delete obj2.b.c }) test('debug handler should print mutations', () => { - let val = '' - const logger = { - log: str => val += str - } - const obj = { a: { b: [1] }} - const observed = observe(obj, { bubble: true, deep: true }) - observed.__handler = all([debug(logger), debug()]) - observed.a.b[0] = 2 - expect(val).toBe('a.b[0] = 2') + let val = '' + const logger = { + log: str => val += str + } + const obj = { a: { b: [1] }} + const observed = observe(obj, { bubble: true, deep: true }) + observed.__handler = all([debug(logger), debug()]) + observed.a.b[0] = 2 + expect(val).toBe('a.b[0] = 2') }) test('all handler should run handlers sequentially', () => { - let val = '' - let count = 0 - const logger = { - log: () => { - val += count - count++ - } + let val = '' + let count = 0 + const logger = { + log: () => { + val += count + count++ } - const obj = { a: { b: [1] }} - const observed = observe(obj, { bubble: true, deep: true }) - observed.__handler = all([debug(logger), debug(logger)]) - observed.a.b[0] = 2 - expect(val).toBe('01') + } + const obj = { a: { b: [1] }} + const observed = observe(obj, { bubble: true, deep: true }) + observed.__handler = all([debug(logger), debug(logger)]) + observed.a.b[0] = 2 + expect(val).toBe('01') - // Improves coverage - const observed2 = observe(obj, { bubble: true, deep: true }) - observed2.__handler = all(debug(logger)) - observed2.a.b[0] = 3 - expect(val).toBe('012') + // Improves coverage + const observed2 = observe(obj, { bubble: true, deep: true }) + observed2.__handler = all(debug(logger)) + observed2.a.b[0] = 3 + expect(val).toBe('012') }) test('a handler that returns false should stop the bubbling', () => { - let val = '' - const logger = { - log: str => val += str - } - const obj = { - a: { - b: [1], - c: { - d: 0, - __handler: () => { - val = '' - return false - } - } + let val = '' + const logger = { + log: str => val += str + } + const obj = { + a: { + b: [1], + c: { + d: 0, + __handler: () => { + val = '' + return false } + } } - const observed = observe(obj, { bubble: true, deep: true }) - observed.__handler = debug(logger) - observed.a.b[0] = 2 - expect(val).toBe('a.b[0] = 2') - observed.a.c.d = 2 - expect(val).toBe('') - observed.a.b = { inner: 1 } - expect(val).toBe('a.b = {\n\t"inner": 1\n}') + } + const observed = observe(obj, { bubble: true, deep: true }) + observed.__handler = debug(logger) + observed.a.b[0] = 2 + expect(val).toBe('a.b[0] = 2') + observed.a.c.d = 2 + expect(val).toBe('') + observed.a.b = { inner: 1 } + expect(val).toBe('a.b = {\n\t"inner": 1\n}') }) test('bubble false should prevent handler bubbling', () => { - let val = '' - const logger = { - log: str => val += str - } - const obj = { - a: { - b: [1], - c: { - d: 0, - __handler: () => { - val = '' - return false - } - } - }, - z: 0 - } - const observed = observe(obj, { bubble: false, deep: true }) - observed.__handler = debug(logger) - observed.a.b[0] = 2 - expect(val).toBe('') - observed.z = 1 - expect(val).toBe('z = 1') - observed.z = { a: 1, b: 2 } - observed.z.a = 0 - expect(val).toBe('z = 1z = {\n\t"a": 1,\n\t"b": 2\n}') + let val = '' + const logger = { + log: str => val += str + } + const obj = { + a: { + b: [1], + c: { + d: 0, + __handler: () => { + val = '' + return false + } + } + }, + z: 0 + } + const observed = observe(obj, { bubble: false, deep: true }) + observed.__handler = debug(logger) + observed.a.b[0] = 2 + expect(val).toBe('') + observed.z = 1 + expect(val).toBe('z = 1') + observed.z = { a: 1, b: 2 } + observed.z.a = 0 + expect(val).toBe('z = 1z = {\n\t"a": 1,\n\t"b": 2\n}') }) \ No newline at end of file diff --git a/test/http.test.js b/test/http.test.js index d72091b..3b4c82b 100644 --- a/test/http.test.js +++ b/test/http.test.js @@ -4,421 +4,421 @@ import { request, normalized, resource } from '../src/http' const { observe } = hyperactiv wretch().polyfills({ - fetch: require('node-fetch') + fetch: require('node-fetch') }) function sleep(ms = 250) { - return new Promise(resolve => { - setTimeout(resolve, ms) - }) + return new Promise(resolve => { + setTimeout(resolve, ms) + }) } describe('React http test suite', () => { - describe('request', () => { - - it('should fetch data', async () => { - const store = observe({}) - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - text() { - return Promise.resolve('text') - } - }) - ]) - - const { data, future } = request('/text', { - store, - client: fakeClient, - bodyType: 'text' - }) - expect(data).toBe(null) - await expect(future).resolves.toBe('text') - expect(store.__requests__['get@/text']).toBe('text') + describe('request', () => { + + it('should fetch data', async () => { + const store = observe({}) + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + text() { + return Promise.resolve('text') + } }) + ]) + + const { data, future } = request('/text', { + store, + client: fakeClient, + bodyType: 'text' + }) + expect(data).toBe(null) + await expect(future).resolves.toBe('text') + expect(store.__requests__['get@/text']).toBe('text') + }) - it('should throw if wretch errored', async () => { - const store = observe({}) + it('should throw if wretch errored', async () => { + const store = observe({}) - const { data, future } = request('error', { - store - }) - expect(data).toBe(null) - await expect(future).rejects.toThrow('Only absolute URLs are supported') - }) + const { data, future } = request('error', { + store + }) + expect(data).toBe(null) + await expect(future).rejects.toThrow('Only absolute URLs are supported') + }) - it('should fetch data from the network', async () => { - const store = observe({}) - - let counter = 0 - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return ( - counter++ === 0 ? - { hello: 'hello world'} : - { hello: 'bonjour le monde'} - ) - } - }) - ]) - - const { future } = request('/hello', { - store, - client: fakeClient - }) - - await expect(future).resolves.toStrictEqual({ hello: 'hello world' }) - - const { future: networkFuture } = request('/hello', { - store, - client: fakeClient, - policy: 'network-only' - }) - - await expect(networkFuture).resolves.toStrictEqual({ hello: 'bonjour le monde' }) - - expect(request('/hello', { - store, - client: fakeClient - }).data).toStrictEqual({ hello: 'bonjour le monde' }) + it('should fetch data from the network', async () => { + const store = observe({}) + + let counter = 0 + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return ( + counter++ === 0 ? + { hello: 'hello world'} : + { hello: 'bonjour le monde'} + ) + } }) - }) + ]) + + const { future } = request('/hello', { + store, + client: fakeClient + }) + + await expect(future).resolves.toStrictEqual({ hello: 'hello world' }) - const payload = { + const { future: networkFuture } = request('/hello', { + store, + client: fakeClient, + policy: 'network-only' + }) + + await expect(networkFuture).resolves.toStrictEqual({ hello: 'bonjour le monde' }) + + expect(request('/hello', { + store, + client: fakeClient + }).data).toStrictEqual({ hello: 'bonjour le monde' }) + }) + }) + + const payload = { + id: 1, + title: 'My Item', + post: { id: 4, date: '01-01-1970' }, + users: [{ + userId: 1, + name: 'john' + }, { + userId: 2, + name: 'jane', + comments: [{ + id: 3, + subId: 1, + content: 'Hello' + }] + }] + } + + const normalizedPayload = { + items: { + 1: { id: 1, title: 'My Item', - post: { id: 4, date: '01-01-1970' }, - users: [{ - userId: 1, - name: 'john' - }, { - userId: 2, - name: 'jane', - comments: [{ - id: 3, - subId: 1, - content: 'Hello' - }] - }] + post: 4, + users: [ 1, 2 ] + } + }, + users: { + 1: { userId: 1, name: 'john' }, + 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } + }, + posts: { + 4: { id: 4, date: '01-01-1970' } + }, + comments: { + '3 - 1': { id: 3, subId: 1, content: 'Hello' } + }, + itemsContainer: { + container_1: { + items: 1 + } } - - const normalizedPayload = { - items: { - 1: { - id: 1, - title: 'My Item', - post: 4, - users: [ 1, 2 ] - } - }, - users: { - 1: { userId: 1, name: 'john' }, - 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } - }, - posts: { - 4: { id: 4, date: '01-01-1970' } - }, - comments: { - '3 - 1': { id: 3, subId: 1, content: 'Hello' } - }, - itemsContainer: { - container_1: { - items: 1 - } - } - } - - const normalizeOptions = { - entity: 'items', - schema: [ - [ 'post', { mapping: 'posts' } ], - [ 'users', - [ - ['comments', { - key: comment => comment.id + ' - ' + comment.subId - }] - ], - { - key: 'userId' - } - ] + } + + const normalizeOptions = { + entity: 'items', + schema: [ + [ 'post', { mapping: 'posts' } ], + [ 'users', + [ + ['comments', { + key: comment => comment.id + ' - ' + comment.subId + }] ], - from: { - itemsContainer: 'container_1' + { + key: 'userId' } + ] + ], + from: { + itemsContainer: 'container_1' } - - describe('normalized', () => { - - it('should fetch data and normalize it', async () => { - const store = observe({}) - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve(payload) - } - }) - ]) - - const { data, future } = normalized('/item/1', { - store, - client: fakeClient, - normalize: normalizeOptions - }) - expect(data).toBe(null) - await expect(future).resolves.toStrictEqual(normalizedPayload) - expect(store.__requests__['get@/item/1']).toStrictEqual({ - items: ['1'], - users: ['1', '2'], - posts: ['4'], - comments: ['3 - 1'], - itemsContainer: [ - 'container_1' - ] - }) - }) - - it('should throw if wretch errored', async () => { - const store = observe({}) - - const { data, future } = normalized('error', { - store - }) - expect(data).toBe(null) - await expect(future).rejects.toThrow('Only absolute URLs are supported') + } + + describe('normalized', () => { + + it('should fetch data and normalize it', async () => { + const store = observe({}) + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve(payload) + } }) + ]) + + const { data, future } = normalized('/item/1', { + store, + client: fakeClient, + normalize: normalizeOptions + }) + expect(data).toBe(null) + await expect(future).resolves.toStrictEqual(normalizedPayload) + expect(store.__requests__['get@/item/1']).toStrictEqual({ + items: ['1'], + users: ['1', '2'], + posts: ['4'], + comments: ['3 - 1'], + itemsContainer: [ + 'container_1' + ] + }) + }) - it('should fetch data from the network', async () => { - const store = observe({}) - - let counter = 0 - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return ( - counter++ >= 1 ? - { - ...payload, - title: 'Updated Title' - } : - payload - ) - } - }) - ]) - - const { future } = normalized( - '/item/1', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ) + it('should throw if wretch errored', async () => { + const store = observe({}) - const data = await future - expect(data.items['1'].title).toBe('My Item') + const { data, future } = normalized('error', { + store + }) + expect(data).toBe(null) + await expect(future).rejects.toThrow('Only absolute URLs are supported') + }) - const { future: networkFuture } = normalized( - '/item/1', + it('should fetch data from the network', async () => { + const store = observe({}) + + let counter = 0 + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return ( + counter++ >= 1 ? { - store, - client: fakeClient, - normalize: normalizeOptions, - policy: 'network-only' - } + ...payload, + title: 'Updated Title' + } : + payload ) + } + }) + ]) + + const { future } = normalized( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + const data = await future + expect(data.items['1'].title).toBe('My Item') + + const { future: networkFuture } = normalized( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions, + policy: 'network-only' + } + ) - const networkData = await networkFuture - expect(networkData.items['1'].title).toBe('Updated Title') + const networkData = await networkFuture + expect(networkData.items['1'].title).toBe('Updated Title') - expect(normalized( - '/item/1', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ).data.items['1'].title).toBe('Updated Title') + expect(normalized( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ).data.items['1'].title).toBe('Updated Title') + }) + }) + + describe('resource', () => { + + it('should fetch a single resource, normalize it and return the data', async () => { + const store = observe({}) + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve(payload) + } }) + ]) + + const { data, future } = resource('items', + '/item/1', + { + id: 1, + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + expect(data).toBe(null) + await expect(future).resolves.toStrictEqual(normalizedPayload.items['1']) + expect(store.__requests__['get@/item/1']).toStrictEqual({ + items: ['1'], + users: ['1', '2'], + posts: ['4'], + comments: ['3 - 1'], + itemsContainer: [ + 'container_1' + ] + }) }) - describe('resource', () => { - - it('should fetch a single resource, normalize it and return the data', async () => { - const store = observe({}) - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve(payload) - } - }) - ]) - - const { data, future } = resource('items', - '/item/1', - { - id: 1, - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - expect(data).toBe(null) - await expect(future).resolves.toStrictEqual(normalizedPayload.items['1']) - expect(store.__requests__['get@/item/1']).toStrictEqual({ - items: ['1'], - users: ['1', '2'], - posts: ['4'], - comments: ['3 - 1'], - itemsContainer: [ - 'container_1' - ] - }) + it('should fetch multiple resources, normalize them and return the data', async () => { + const store = observe({}) + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve([payload]) + } }) + ]) + + const { data, future } = resource( + 'items', + '/items', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + expect(data).toBe(null) + await expect(future).resolves.toStrictEqual([normalizedPayload.items['1']]) + expect(store.__requests__['get@/items']).toStrictEqual({ + items: ['1'], + users: ['1', '2'], + posts: ['4'], + comments: ['3 - 1'], + itemsContainer: [ + 'container_1' + ] + }) + }) - it('should fetch multiple resources, normalize them and return the data', async () => { - const store = observe({}) - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve([payload]) - } - }) - ]) - - const { data, future } = resource( - 'items', - '/items', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - expect(data).toBe(null) - await expect(future).resolves.toStrictEqual([normalizedPayload.items['1']]) - expect(store.__requests__['get@/items']).toStrictEqual({ - items: ['1'], - users: ['1', '2'], - posts: ['4'], - comments: ['3 - 1'], - itemsContainer: [ - 'container_1' - ] - }) + it('should retrieve data from the cache by id', async () => { + const store = observe({ + item: { + 1: { + id: 1, + title: 'Title' + } + }, + __requests__: { + testKey: { + item: [1] + } + } + }) + + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return { + id: 1, + title: 'Updated title' + } + } }) + ]) + + const { data, future } = resource( + 'item', + '/item/1', + { + id: 1, + store, + client: fakeClient, + serialize: () => 'testKey', + policy: 'cache-and-network' + } + ) - it('should retrieve data from the cache by id', async () => { - const store = observe({ - item: { - 1: { - id: 1, - title: 'Title' - } - }, - __requests__: { - testKey: { - item: [1] - } - } - }) - - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return { - id: 1, - title: 'Updated title' - } - } - }) - ]) - - const { data, future } = resource( - 'item', - '/item/1', - { - id: 1, - store, - client: fakeClient, - serialize: () => 'testKey', - policy: 'cache-and-network' - } - ) + expect(data).toStrictEqual({ + id: 1, + title: 'Title' + }) - expect(data).toStrictEqual({ - id: 1, - title: 'Title' - }) + await expect(future).resolves.toStrictEqual({ + id: 1, + title: 'Updated title' + }) + }) - await expect(future).resolves.toStrictEqual({ - id: 1, - title: 'Updated title' - }) + it('should refetch data properly', async () => { + const store = observe({ + item: { + 1: { + id: 1, + title: 'Title' + } + }, + __requests__: { + testKey: { + item: [1] + } + } + }) + + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return { + id: 1, + title: 'Updated title' + } + } }) + ]) + + const { data, refetch } = resource('item', + '/item/1', + { + id: 1, + store, + client: fakeClient, + serialize: () => 'testKey', + policy: 'cache-first' + } + ) - it('should refetch data properly', async () => { - const store = observe({ - item: { - 1: { - id: 1, - title: 'Title' - } - }, - __requests__: { - testKey: { - item: [1] - } - } - }) - - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return { - id: 1, - title: 'Updated title' - } - } - }) - ]) - - const { data, refetch } = resource('item', - '/item/1', - { - id: 1, - store, - client: fakeClient, - serialize: () => 'testKey', - policy: 'cache-first' - } - ) - - expect(data).toStrictEqual({ - id: 1, - title: 'Title' - }) + expect(data).toStrictEqual({ + id: 1, + title: 'Title' + }) - await expect(refetch()).resolves.toStrictEqual({ - id: 1, - title: 'Updated title' - }) - }) + await expect(refetch()).resolves.toStrictEqual({ + id: 1, + title: 'Updated title' + }) }) + }) }) \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js index 8acefb6..d26a3bb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -4,547 +4,547 @@ const { computed, observe, dispose } = hyperactiv const delay = time => new Promise(resolve => setTimeout(resolve, time)) test('simple computation', () => { - const obj = observe({ - a: 1, b: 2 - }) - - let result = 0 - - const sum = computed(() => { - result = obj.a + obj.b - }, { autoRun: false }) - sum() - - expect(result).toBe(3) - obj.a = 2 - expect(result).toBe(4) - obj.b = 3 - expect(result).toBe(5) + const obj = observe({ + a: 1, b: 2 + }) + + let result = 0 + + const sum = computed(() => { + result = obj.a + obj.b + }, { autoRun: false }) + sum() + + expect(result).toBe(3) + obj.a = 2 + expect(result).toBe(4) + obj.b = 3 + expect(result).toBe(5) }) test('auto-run computed function', () => { - const obj = observe({ - a: 1, b: 2 - }) + const obj = observe({ + a: 1, b: 2 + }) - let result = 0 + let result = 0 - computed(() => { - result = obj.a + obj.b - }) + computed(() => { + result = obj.a + obj.b + }) - expect(result).toBe(3) + expect(result).toBe(3) }) test('multiple getters', () => { - const obj = observe({ - a: 1, - b: 2, - sum: 0 - }, { props: [ 'a', 'b' ]}) + const obj = observe({ + a: 1, + b: 2, + sum: 0 + }, { props: [ 'a', 'b' ]}) - computed(() => { - obj.sum += obj.a - obj.sum += obj.b - obj.sum += obj.a + obj.b - }, { autoRun: true }) + computed(() => { + obj.sum += obj.a + obj.sum += obj.b + obj.sum += obj.a + obj.b + }, { autoRun: true }) - // 1 + 2 + 3 - expect(obj.sum).toBe(6) + // 1 + 2 + 3 + expect(obj.sum).toBe(6) - obj.a = 2 + obj.a = 2 - // 6 + 2 + 2 + 4 - expect(obj.sum).toBe(14) + // 6 + 2 + 2 + 4 + expect(obj.sum).toBe(14) }) test('nested functions', () => { - const obj = observe({ - a: 1, - b: 2, - c: 3, - d: 4 - }) - - let result - - const aPlusB = () => obj.a + obj.b - const cPlusD = () => obj.c + obj.d - - computed(() => { - result = aPlusB() + cPlusD() - }) - - expect(result).toBe(10) - obj.a = 2 - expect(result).toBe(11) - obj.d = 5 - expect(result).toBe(12) + const obj = observe({ + a: 1, + b: 2, + c: 3, + d: 4 + }) + + let result + + const aPlusB = () => obj.a + obj.b + const cPlusD = () => obj.c + obj.d + + computed(() => { + result = aPlusB() + cPlusD() + }) + + expect(result).toBe(10) + obj.a = 2 + expect(result).toBe(11) + obj.d = 5 + expect(result).toBe(12) }) test('multiple observed objects', () => { - const obj1 = observe({ a: 1 }) - const obj2 = observe({ a: 2 }) - const obj3 = observe({ a: 3 }) - - let result = 0 - - computed(() => { - result = obj1.a + obj2.a + obj3.a - }) - - expect(result).toBe(6) - obj1.a = 0 - expect(result).toBe(5) - obj2.a = 0 - expect(result).toBe(3) - obj3.a = 0 - expect(result).toBe(0) + const obj1 = observe({ a: 1 }) + const obj2 = observe({ a: 2 }) + const obj3 = observe({ a: 3 }) + + let result = 0 + + computed(() => { + result = obj1.a + obj2.a + obj3.a + }) + + expect(result).toBe(6) + obj1.a = 0 + expect(result).toBe(5) + obj2.a = 0 + expect(result).toBe(3) + obj3.a = 0 + expect(result).toBe(0) }) test('circular computed function', () => { - const obj = observe({ a: 1, b: 1 }) - computed(() => { - obj.a += obj.b - }) - expect(obj.a).toBe(2) - obj.b = 2 - expect(obj.a).toBe(4) - obj.a = 3 - expect(obj.a).toBe(5) + const obj = observe({ a: 1, b: 1 }) + computed(() => { + obj.a += obj.b + }) + expect(obj.a).toBe(2) + obj.b = 2 + expect(obj.a).toBe(4) + obj.a = 3 + expect(obj.a).toBe(5) }) test('array methods', () => { - const arr = observe([{ val: 1 }, { val: 2 }, { val: 3 }]) - let sum = 0 - computed(() => { sum = arr.reduce((acc, { val }) => acc + val, 0) }) - expect(sum).toBe(6) - arr.push({ val: 4 }) - expect(sum).toBe(10) - arr.pop() - expect(sum).toBe(6) - arr.unshift({ val: 5 }, { val: 4 }) - expect(sum).toBe(15) - arr.shift() - expect(sum).toBe(10) - arr.splice(1, 3) - expect(sum).toBe(4) + const arr = observe([{ val: 1 }, { val: 2 }, { val: 3 }]) + let sum = 0 + computed(() => { sum = arr.reduce((acc, { val }) => acc + val, 0) }) + expect(sum).toBe(6) + arr.push({ val: 4 }) + expect(sum).toBe(10) + arr.pop() + expect(sum).toBe(6) + arr.unshift({ val: 5 }, { val: 4 }) + expect(sum).toBe(15) + arr.shift() + expect(sum).toBe(10) + arr.splice(1, 3) + expect(sum).toBe(4) }) test('dispose computed functions', () => { - const obj = observe({ a: 0 }) - let result = 0 - let result2 = 0 - - const minusOne = computed(() => { - result2 = obj.a - 1 - }) - computed(() => { - result = obj.a + 1 - }) - - obj.a = 1 - expect(result).toBe(2) - expect(result2).toBe(0) - dispose(minusOne) - obj.a = 10 - expect(result).toBe(11) - expect(result2).toBe(0) + const obj = observe({ a: 0 }) + let result = 0 + let result2 = 0 + + const minusOne = computed(() => { + result2 = obj.a - 1 + }) + computed(() => { + result = obj.a + 1 + }) + + obj.a = 1 + expect(result).toBe(2) + expect(result2).toBe(0) + dispose(minusOne) + obj.a = 10 + expect(result).toBe(11) + expect(result2).toBe(0) }) test('does not observe the original object', () => { - const obj = { a: 1 } - const obs = observe(obj) - let plusOne = 0 - computed(() => { - plusOne = obs.a + 1 - }) - expect(plusOne).toBe(2) - obj.a = 2 - expect(plusOne).toBe(2) - obs.a = 3 - expect(plusOne).toBe(4) + const obj = { a: 1 } + const obs = observe(obj) + let plusOne = 0 + computed(() => { + plusOne = obs.a + 1 + }) + expect(plusOne).toBe(2) + obj.a = 2 + expect(plusOne).toBe(2) + obs.a = 3 + expect(plusOne).toBe(4) }) test('chain of computations', () => { - const obj = observe({ - a: 0, - b: 0, - c: 0, - d: 0 - }) - - computed(() => { obj.b = obj.a * 2 }) - computed(() => { obj.c = obj.b * 2 }) - computed(() => { obj.d = obj.c * 2 }) - - expect(obj.d).toBe(0) - obj.a = 5 - expect(obj.d).toBe(40) + const obj = observe({ + a: 0, + b: 0, + c: 0, + d: 0 + }) + + computed(() => { obj.b = obj.a * 2 }) + computed(() => { obj.c = obj.b * 2 }) + computed(() => { obj.d = obj.c * 2 }) + + expect(obj.d).toBe(0) + obj.a = 5 + expect(obj.d).toBe(40) }) test('asynchronous computation', async () => { - const obj = observe({ a: 0, b: 0 }) - - const addOne = () => { - obj.b = obj.a + 1 - } - const delayedAddOne = computed( - ({ computeAsync }) => delay(200).then(() => computeAsync(addOne)), - { autoRun: false } - ) - await delayedAddOne() - - obj.a = 2 - expect(obj.b).toBe(1) - - await delay(250).then(() => { - expect(obj.b).toBe(3) - }) + const obj = observe({ a: 0, b: 0 }) + + const addOne = () => { + obj.b = obj.a + 1 + } + const delayedAddOne = computed( + ({ computeAsync }) => delay(200).then(() => computeAsync(addOne)), + { autoRun: false } + ) + await delayedAddOne() + + obj.a = 2 + expect(obj.b).toBe(1) + + await delay(250).then(() => { + expect(obj.b).toBe(3) + }) }) test('concurrent asynchronous computations', async () => { - const obj = observe({ a: 0, b: 0, c: 0 }) - let result = 0 + const obj = observe({ a: 0, b: 0, c: 0 }) + let result = 0 - const plus = prop => computed(async ({ computeAsync }) => { - await delay(200) - computeAsync(() => result += obj[prop]) - }, { autoRun: false }) - const plusA = plus('a') - const plusB = plus('b') - const plusC = plus('c') + const plus = prop => computed(async ({ computeAsync }) => { + await delay(200) + computeAsync(() => result += obj[prop]) + }, { autoRun: false }) + const plusA = plus('a') + const plusB = plus('b') + const plusC = plus('c') - await Promise.all([ plusA(), plusB(), plusC() ]) + await Promise.all([ plusA(), plusB(), plusC() ]) - expect(result).toBe(0) + expect(result).toBe(0) - obj.a = 1 - obj.b = 2 - obj.c = 3 + obj.a = 1 + obj.b = 2 + obj.c = 3 - await delay(250).then(() => { - expect(result).toBe(6) - }) + await delay(250).then(() => { + expect(result).toBe(6) + }) }) test('observe arrays', () => { - const arr = observe([1, 2, 3]) - let sum = 0 - computed(() => sum = arr.reduce((acc, curr) => acc + curr)) - expect(sum).toBe(6) + const arr = observe([1, 2, 3]) + let sum = 0 + computed(() => sum = arr.reduce((acc, curr) => acc + curr)) + expect(sum).toBe(6) - arr[0] = 2 - expect(sum).toBe(7) + arr[0] = 2 + expect(sum).toBe(7) }) test('usage with "this"', () => { - const obj = observe({ - a: 1, - b: 2, - doSum: function() { - this.sum = this.a + this.b - } - }) - - obj.doSum = computed(obj.doSum.bind(obj)) - expect(obj.sum).toBe(3) - obj.a = 2 - expect(obj.sum).toBe(4) + const obj = observe({ + a: 1, + b: 2, + doSum: function() { + this.sum = this.a + this.b + } + }) + + obj.doSum = computed(obj.doSum.bind(obj)) + expect(obj.sum).toBe(3) + obj.a = 2 + expect(obj.sum).toBe(4) }) test('"class" syntax', () => { - class MyClass { - constructor() { - this.a = 1 - this.b = 2 - - const _this = observe(this) - this.doSum = computed(this.doSum.bind(_this)) - return _this - } - - doSum() { - this.sum = this.a + this.b - } + class MyClass { + constructor() { + this.a = 1 + this.b = 2 + + const _this = observe(this) + this.doSum = computed(this.doSum.bind(_this)) + return _this + } + + doSum() { + this.sum = this.a + this.b } + } - const obj = new MyClass() - expect(obj.sum).toBe(3) - obj.a = 2 - expect(obj.sum).toBe(4) + const obj = new MyClass() + expect(obj.sum).toBe(3) + obj.a = 2 + expect(obj.sum).toBe(4) }) test('not observe ignored properties (props & ignore: array)', () => { - const object = { - a: 0, - b: 0, - sum: 0 - } - const observeA = observe(object, { props: ['a'] }) - const observeB = observe(object, { ignore: ['a', 'sum'] }) - - computed(function() { - observeA.sum = observeA.a + observeB.b - }) - - observeA.a = 2 - expect(object.sum).toBe(2) - observeA.b = 1 - observeB.a = 1 - expect(object.sum).toBe(2) - observeB.b = 2 - expect(object.sum).toBe(3) + const object = { + a: 0, + b: 0, + sum: 0 + } + const observeA = observe(object, { props: ['a'] }) + const observeB = observe(object, { ignore: ['a', 'sum'] }) + + computed(function() { + observeA.sum = observeA.a + observeB.b + }) + + observeA.a = 2 + expect(object.sum).toBe(2) + observeA.b = 1 + observeB.a = 1 + expect(object.sum).toBe(2) + observeB.b = 2 + expect(object.sum).toBe(3) }) test('not observe ignored properties (props & ignore: function)', () => { - const object = { - a: 0, - b: 0, - sum: 0 - } - const observeA = observe(object, { props: key => key === 'a' }) - const observeB = observe(object, { ignore: key => ['a', 'sum'].includes(key) }) - - computed(function() { - observeA.sum = observeA.a + observeB.b - }) - - observeA.a = 2 - expect(object.sum).toBe(2) - observeA.b = 1 - observeB.a = 1 - expect(object.sum).toBe(2) - observeB.b = 2 - expect(object.sum).toBe(3) + const object = { + a: 0, + b: 0, + sum: 0 + } + const observeA = observe(object, { props: key => key === 'a' }) + const observeB = observe(object, { ignore: key => ['a', 'sum'].includes(key) }) + + computed(function() { + observeA.sum = observeA.a + observeB.b + }) + + observeA.a = 2 + expect(object.sum).toBe(2) + observeA.b = 1 + observeB.a = 1 + expect(object.sum).toBe(2) + observeB.b = 2 + expect(object.sum).toBe(3) }) test('batch computations', async () => { - expect.assertions(6) + expect.assertions(6) - const array = observe([0, 0, 0], { batch: true }) - let sum = 0 + const array = observe([0, 0, 0], { batch: true }) + let sum = 0 - computed(() => { - expect(true).toBe(true) - sum = array.reduce((acc, curr) => acc + curr) - }) + computed(() => { + expect(true).toBe(true) + sum = array.reduce((acc, curr) => acc + curr) + }) - expect(sum).toBe(0) + expect(sum).toBe(0) - array[0] = 0 - array[0] = 1 - array[1] = 2 - array[2] = 3 + array[0] = 0 + array[0] = 1 + array[1] = 2 + array[2] = 3 - await delay(100) - expect(sum).toBe(6) + await delay(100) + expect(sum).toBe(6) - array[0] = 6 - array[0] = 7 - array[1] = 8 - array[2] = 10 + array[0] = 6 + array[0] = 7 + array[1] = 8 + array[2] = 10 - await delay(100) - expect(sum).toBe(25) + await delay(100) + expect(sum).toBe(25) }) test('batch computations with custom debounce', async () => { - expect.assertions(8) + expect.assertions(8) - const array = observe([0, 0, 0], { batch: 1000 }) - let sum = 0 + const array = observe([0, 0, 0], { batch: 1000 }) + let sum = 0 - computed(() => { - expect(true).toBe(true) - sum = array.reduce((acc, curr) => acc + curr) - }) + computed(() => { + expect(true).toBe(true) + sum = array.reduce((acc, curr) => acc + curr) + }) - expect(sum).toBe(0) + expect(sum).toBe(0) - array[0] = 0 - array[1] = 2 - array[2] = 3 + array[0] = 0 + array[1] = 2 + array[2] = 3 - await delay(500) - expect(sum).toBe(0) - array[0] = 1 - await delay(500) - expect(sum).toBe(6) + await delay(500) + expect(sum).toBe(0) + array[0] = 1 + await delay(500) + expect(sum).toBe(6) - array[0] = 6 - array[1] = 8 - array[2] = 10 + array[0] = 6 + array[1] = 8 + array[2] = 10 - await delay(500) - array[0] = 7 - expect(sum).toBe(6) - await delay(500) - expect(sum).toBe(25) + await delay(500) + array[0] = 7 + expect(sum).toBe(6) + await delay(500) + expect(sum).toBe(25) }) test('run a callback instead of the computed function', () => { - const obj = observe({ - a: 1, b: 0 - }) - - const incrementB = () => { - obj.b++ - } - computed(() => { - expect(obj.a).toBe(1) - }, { callback: incrementB }) - - expect(obj.b).toBe(0) - obj.a = 2 - expect(obj.a).toBe(2) - expect(obj.b).toBe(1) + const obj = observe({ + a: 1, b: 0 + }) + + const incrementB = () => { + obj.b++ + } + computed(() => { + expect(obj.a).toBe(1) + }, { callback: incrementB }) + + expect(obj.b).toBe(0) + obj.a = 2 + expect(obj.a).toBe(2) + expect(obj.b).toBe(1) }) test('deep observe nested objects and new properties', () => { - const o = { a: { b: 1 }, tab: [{ z: 1 }]} - Object.setPrototypeOf(o, { _unused: true }) - const obj = observe(o) - - obj.c = { d: { e: 2 } } - - computed(() => { - obj.sum = (obj.a && obj.a.b) + obj.c.d.e + obj.tab[0].z - }) - expect(obj.sum).toBe(4) - obj.a.b = 2 - expect(obj.sum).toBe(5) - obj.c.d.e = 3 - expect(obj.sum).toBe(6) - obj.tab[0].z = 2 - expect(obj.sum).toBe(7) - - // null check - obj.a = null - expect(obj.sum).toBe(5) + const o = { a: { b: 1 }, tab: [{ z: 1 }]} + Object.setPrototypeOf(o, { _unused: true }) + const obj = observe(o) + + obj.c = { d: { e: 2 } } + + computed(() => { + obj.sum = (obj.a && obj.a.b) + obj.c.d.e + obj.tab[0].z + }) + expect(obj.sum).toBe(4) + obj.a.b = 2 + expect(obj.sum).toBe(5) + obj.c.d.e = 3 + expect(obj.sum).toBe(6) + obj.tab[0].z = 2 + expect(obj.sum).toBe(7) + + // null check + obj.a = null + expect(obj.sum).toBe(5) }) test('shallow observe nested objects when deep is false', () => { - const o = { a: { b: 1 }, c: 1, tab: [{ z: 1 }]} - const obj = observe(o, { deep: false }) - obj.d = { e: { f: 2 } } - computed(() => { - obj.sum = obj.a.b + obj.c + obj.d.e.f + obj.tab[0].z - }) - expect(obj.sum).toBe(5) - obj.a.b = 2 - expect(obj.sum).toBe(5) - obj.c = 2 - expect(obj.sum).toBe(7) + const o = { a: { b: 1 }, c: 1, tab: [{ z: 1 }]} + const obj = observe(o, { deep: false }) + obj.d = { e: { f: 2 } } + computed(() => { + obj.sum = obj.a.b + obj.c + obj.d.e.f + obj.tab[0].z + }) + expect(obj.sum).toBe(5) + obj.a.b = 2 + expect(obj.sum).toBe(5) + obj.c = 2 + expect(obj.sum).toBe(7) }) test('bind methods to the observed object', () => { - const obj = observe({ - a: 1, - b: 1, - c: new Date(), - doSum: function() { - this.sum = this.a + this.b - } - }, { bind: true }) - - obj.doSum = computed(obj.doSum) - expect(obj.sum).toBe(2) - obj.a = 2 - expect(obj.sum).toBe(3) + const obj = observe({ + a: 1, + b: 1, + c: new Date(), + doSum: function() { + this.sum = this.a + this.b + } + }, { bind: true }) + + obj.doSum = computed(obj.doSum) + expect(obj.sum).toBe(2) + obj.a = 2 + expect(obj.sum).toBe(3) }) test('bind methods to the observed class', () => { - class TestClass { - constructor() { - this.a = 1 - this.b = 2 - } - method() { - this.sum = this.a + this.b - } + class TestClass { + constructor() { + this.a = 1 + this.b = 2 } - const observer = observe(new TestClass(), { bind: true }) - observer.method = computed(observer.method) - expect(observer.sum).toBe(3) - observer.a = 2 - expect(observer.sum).toBe(4) + method() { + this.sum = this.a + this.b + } + } + const observer = observe(new TestClass(), { bind: true }) + observer.method = computed(observer.method) + expect(observer.sum).toBe(3) + observer.a = 2 + expect(observer.sum).toBe(4) }) test('bind computed functions using the bind option', () => { - const obj = observe({ - a: 1, - b: 2, - doSum: function() { - this.sum = this.a + this.b - } - }) - - obj.doSum = computed(obj.doSum, { bind: obj }) - expect(obj.sum).toBe(3) - obj.a = 2 - expect(obj.sum).toBe(4) + const obj = observe({ + a: 1, + b: 2, + doSum: function() { + this.sum = this.a + this.b + } + }) + + obj.doSum = computed(obj.doSum, { bind: obj }) + expect(obj.sum).toBe(3) + obj.a = 2 + expect(obj.sum).toBe(4) }) test('track unused function dependencies', () => { - const observed = observe({ - condition: true, - a: 0, - b: 0 - }) - - const object = { - counter: 0 + const observed = observe({ + condition: true, + a: 0, + b: 0 + }) + + const object = { + counter: 0 + } + + computed(() => { + if(observed.condition) { + observed.a++ + } else { + observed.b++ } - - computed(() => { - if(observed.condition) { - observed.a++ - } else { - observed.b++ - } - object.counter++ - }) - - expect(object.counter).toBe(1) - observed.a++ - expect(object.counter).toBe(2) - observed.condition = false - expect(object.counter).toBe(3) - observed.a++ - expect(object.counter).toBe(3) - observed.b++ - expect(object.counter).toBe(4) + object.counter++ + }) + + expect(object.counter).toBe(1) + observed.a++ + expect(object.counter).toBe(2) + observed.condition = false + expect(object.counter).toBe(3) + observed.a++ + expect(object.counter).toBe(3) + observed.b++ + expect(object.counter).toBe(4) }) test('not track unused function dependencies if the disableTracking flag is true', () => { - const observed = observe({ - condition: true, - a: 0, - b: 0 - }) - - const object = { - counter: 0 + const observed = observe({ + condition: true, + a: 0, + b: 0 + }) + + const object = { + counter: 0 + } + + computed(() => { + if(observed.condition) { + observed.a++ + } else { + observed.b++ } - - computed(() => { - if(observed.condition) { - observed.a++ - } else { - observed.b++ - } - object.counter++ - }, { disableTracking: true }) - - expect(object.counter).toBe(1) - observed.a++ - expect(object.counter).toBe(2) - observed.condition = false - expect(object.counter).toBe(3) - observed.a++ - expect(object.counter).toBe(4) - observed.b++ - expect(object.counter).toBe(5) + object.counter++ + }, { disableTracking: true }) + + expect(object.counter).toBe(1) + observed.a++ + expect(object.counter).toBe(2) + observed.condition = false + expect(object.counter).toBe(3) + observed.a++ + expect(object.counter).toBe(4) + observed.b++ + expect(object.counter).toBe(5) }) \ No newline at end of file diff --git a/test/react/components.test.js b/test/react/components.test.js index 09129fc..0443e33 100644 --- a/test/react/components.test.js +++ b/test/react/components.test.js @@ -4,158 +4,158 @@ import React from 'react' import { - render, - fireEvent, - cleanup, - waitFor + render, + fireEvent, + cleanup, + waitFor } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import { - Watch, - watch, - store as createStore, - HyperactivProvider + Watch, + watch, + store as createStore, + HyperactivProvider } from '../../src/react' afterEach(cleanup) describe('React components test suite', () => { - const commonJsx = store => -
- store.firstName = e.target.value } - /> - store.lastName = e.target.value } - /> -
+ const commonJsx = store => +
+ store.firstName = e.target.value } + /> + store.lastName = e.target.value } + /> +
Hello, { store.firstName } { store.lastName } ! -
-
- - async function testStoreUpdate(Component, store) { - const { getByTestId } = render() - - expect(getByTestId('firstname')).toHaveValue(store.firstName) - expect(getByTestId('lastname')).toHaveValue(store.lastName) - expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) - - fireEvent.change(getByTestId('firstname'), { - target: { - value: 'John' - } - }) - - fireEvent.change(getByTestId('lastname'), { - target: { - value: 'Doe' - } - }) - - expect(store).toEqual({ firstName: 'John', lastName: 'Doe' }) - - await waitFor(() => { - expect(getByTestId('firstname')).toHaveValue(store.firstName) - expect(getByTestId('lastname')).toHaveValue(store.lastName) - expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) - }) - } - - describe('watch()', () => { - - it('should observe a class component', () => { - const store = createStore({ - firstName: 'Igor', - lastName: 'Gonzola' - }) - const ClassComponent = watch(class extends React.Component { - render() { - return commonJsx(store) - } - }) - - return testStoreUpdate(ClassComponent, store) - }) - - it('should observe a functional component', () => { - const store = createStore({ - firstName: 'Igor', - lastName: 'Gonzola' - }) - const FunctionalComponent = watch(() => - commonJsx(store) - ) - - return testStoreUpdate(FunctionalComponent, store) - }) - - test('wrapping a functional component should inject the `store` prop', () => { - const store = createStore({ - hello: 'World' - }) - const Wrapper = watch(props =>
{props.store && props.store.hello}
) - const { getByTestId } = render( - - ) - expect(getByTestId('hello-div')).toContainHTML('') - const { getByText } = render( - - - - ) - expect(getByText('World')).toBeTruthy() - }) - - test('wrapping a functional component should not inject the `store` prop if a prop with this name already exists', () => { - const store = createStore({ - hello: 'World' - }) - const Wrapper = watch(props =>
{props.store && props.store.hello}
) - const { getByTestId } = render( - - - - ) - expect(getByTestId('hello-div')).toHaveTextContent('bonjour') - }) - - test('wrapping a class component should gracefully unmount if the child component has a componentWillUnmount method', () => { - let unmounted = false - const Wrapper = watch(class extends React.Component { - componentWillUnmount() { - unmounted = true - } - render() { - return
Hello
- } - }) - const { getByText, unmount } = render() - expect(unmounted).toBe(false) - expect(getByText('Hello')).toBeTruthy() - unmount() - expect(unmounted).toBe(true) - }) +
+
+ + async function testStoreUpdate(Component, store) { + const { getByTestId } = render() + + expect(getByTestId('firstname')).toHaveValue(store.firstName) + expect(getByTestId('lastname')).toHaveValue(store.lastName) + expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) + + fireEvent.change(getByTestId('firstname'), { + target: { + value: 'John' + } + }) + + fireEvent.change(getByTestId('lastname'), { + target: { + value: 'Doe' + } + }) + + expect(store).toEqual({ firstName: 'John', lastName: 'Doe' }) + + await waitFor(() => { + expect(getByTestId('firstname')).toHaveValue(store.firstName) + expect(getByTestId('lastname')).toHaveValue(store.lastName) + expect(getByTestId('hello')).toHaveTextContent(`Hello, ${store.firstName} ${store.lastName} !`) + }) + } + + describe('watch()', () => { + + it('should observe a class component', () => { + const store = createStore({ + firstName: 'Igor', + lastName: 'Gonzola' + }) + const ClassComponent = watch(class extends React.Component { + render() { + return commonJsx(store) + } + }) + + return testStoreUpdate(ClassComponent, store) }) - describe('', () => { - it('should observe its render function', () => { - const store = createStore({ - firstName: 'Igor', - lastName: 'Gonzola' - }) - const ComponentWithWatch = () => - commonJsx(store)} /> - - return testStoreUpdate(ComponentWithWatch, store) - }) - it('should not render anything if no render prop is passed', () => { - const { container } = render() - expect(container).toContainHTML('') - }) + it('should observe a functional component', () => { + const store = createStore({ + firstName: 'Igor', + lastName: 'Gonzola' + }) + const FunctionalComponent = watch(() => + commonJsx(store) + ) + + return testStoreUpdate(FunctionalComponent, store) + }) + + test('wrapping a functional component should inject the `store` prop', () => { + const store = createStore({ + hello: 'World' + }) + const Wrapper = watch(props =>
{props.store && props.store.hello}
) + const { getByTestId } = render( + + ) + expect(getByTestId('hello-div')).toContainHTML('') + const { getByText } = render( + + + + ) + expect(getByText('World')).toBeTruthy() + }) + + test('wrapping a functional component should not inject the `store` prop if a prop with this name already exists', () => { + const store = createStore({ + hello: 'World' + }) + const Wrapper = watch(props =>
{props.store && props.store.hello}
) + const { getByTestId } = render( + + + + ) + expect(getByTestId('hello-div')).toHaveTextContent('bonjour') + }) + + test('wrapping a class component should gracefully unmount if the child component has a componentWillUnmount method', () => { + let unmounted = false + const Wrapper = watch(class extends React.Component { + componentWillUnmount() { + unmounted = true + } + render() { + return
Hello
+ } + }) + const { getByText, unmount } = render() + expect(unmounted).toBe(false) + expect(getByText('Hello')).toBeTruthy() + unmount() + expect(unmounted).toBe(true) + }) + }) + + describe('', () => { + it('should observe its render function', () => { + const store = createStore({ + firstName: 'Igor', + lastName: 'Gonzola' + }) + const ComponentWithWatch = () => + commonJsx(store)} /> + + return testStoreUpdate(ComponentWithWatch, store) + }) + it('should not render anything if no render prop is passed', () => { + const { container } = render() + expect(container).toContainHTML('') }) + }) }) diff --git a/test/react/context.test.js b/test/react/context.test.js index 7cf216e..ed35802 100644 --- a/test/react/context.test.js +++ b/test/react/context.test.js @@ -6,221 +6,221 @@ import React from 'react' import wretch from 'wretch' import { normaliz } from 'normaliz' import { - render, - cleanup + render, + cleanup } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import TestRenderer from 'react-test-renderer' import { - store as createStore, - HyperactivProvider, - HyperactivContext, - preloadData, - useRequest, - useNormalizedRequest, - useStore, - useClient, - setHooksDependencies + store as createStore, + HyperactivProvider, + HyperactivContext, + preloadData, + useRequest, + useNormalizedRequest, + useStore, + useClient, + setHooksDependencies } from '../../src/react' afterEach(cleanup) setHooksDependencies({ wretch, normaliz }) wretch().polyfills({ - fetch: require('node-fetch') + fetch: require('node-fetch') }) const fakeClient = wretch().middlewares([ - () => url => - Promise.resolve({ - ok: true, - json() { - switch (url) { - case '/error': - return Promise.reject('rejected') - case '/hello': - return Promise.resolve({ hello: 'hello world'}) - case '/bonjour': - return Promise.resolve({ bonjour: 'bonjour le monde'}) - case '/entity': - return Promise.resolve({ id: 1 }) - } - } - }) + () => url => + Promise.resolve({ + ok: true, + json() { + switch (url) { + case '/error': + return Promise.reject('rejected') + case '/hello': + return Promise.resolve({ hello: 'hello world'}) + case '/bonjour': + return Promise.resolve({ bonjour: 'bonjour le monde'}) + case '/entity': + return Promise.resolve({ id: 1 }) + } + } + }) ]) const SSRComponent = ({ error, errorNormalized, noSSR }) => { - const { data, loading } = useRequest( - error ? '/error' : '/hello', - { - serialize: () => 'test', - ssr: !noSSR - } + const { data, loading } = useRequest( + error ? '/error' : '/hello', + { + serialize: () => 'test', + ssr: !noSSR + } + ) + const { data: data2 } = useRequest( + '/bonjour', + { + skip: () => loading, + serialize: () => 'test2', + ssr: !noSSR + } + ) + const { data: data3 } = useNormalizedRequest( + errorNormalized ? '/error' : '/entity', + { + skip: () => loading, + serialize: () => 'test3', + normalize: { + schema: [], + entity: 'entity' + }, + ssr: !noSSR + } + ) + return
{data && data.hello} {data2 && data2.bonjour} {data3 && data3.entity['1'].id }
+} + +describe('React context test suite', () => { + test('Context provider should inject a client and a store', () => { + const client = 'client' + const store = 'store' + const { getByText } = render( + + + { value =>
{value.store} {value.client}
} +
+
) - const { data: data2 } = useRequest( - '/bonjour', - { - skip: () => loading, - serialize: () => 'test2', - ssr: !noSSR - } + expect(getByText('store client')).toBeTruthy() + }) + + test('Context provider should not inject anything by default', () => { + const { getByText } = render( + + + { value =>
{value && value.store || 'nothing'} {value && value.client || 'here'}
} +
+
) - const { data: data3 } = useNormalizedRequest( - errorNormalized ? '/error' : '/entity', - { - skip: () => loading, - serialize: () => 'test3', - normalize: { - schema: [], - entity: 'entity' - }, - ssr: !noSSR - } + expect(getByText('nothing here')).toBeTruthy() + }) + + test('useStore and useClient should read the store and client from the context', () => { + const client = 'client' + const store = 'store' + const Component = () => { + const store = useStore() + const client = useClient() + + return
{store} {client}
+ } + const { getByText } = render( + + + ) - return
{data && data.hello} {data2 && data2.bonjour} {data3 && data3.entity['1'].id }
-} + expect(getByText('store client')).toBeTruthy() + }) -describe('React context test suite', () => { - test('Context provider should inject a client and a store', () => { - const client = 'client' - const store = 'store' - const { getByText } = render( - - - { value =>
{value.store} {value.client}
} -
+ test('SSR Provider and preloadData should resolve promises and render markup', async () => { + const store = createStore({}) + const jsx = + + - ) - expect(getByText('store client')).toBeTruthy() - }) - test('Context provider should not inject anything by default', () => { - const { getByText } = render( - - - { value =>
{value && value.store || 'nothing'} {value && value.client || 'here'}
} -
-
- ) - expect(getByText('nothing here')).toBeTruthy() + await TestRenderer.act(async () => { + await preloadData(jsx) }) - test('useStore and useClient should read the store and client from the context', () => { - const client = 'client' - const store = 'store' - const Component = () => { - const store = useStore() - const client = useClient() - - return
{store} {client}
+ expect(store).toEqual({ + entity: { + 1: { + id: 1 } - const { getByText } = render( - - - - ) - expect(getByText('store client')).toBeTruthy() + }, + __requests__: { + test: { + hello: 'hello world' + }, + test2: { + bonjour: 'bonjour le monde' + }, + test3: { + entity: [ '1' ] + } + } }) - test('SSR Provider and preloadData should resolve promises and render markup', async () => { - const store = createStore({}) - const jsx = - - - - - await TestRenderer.act(async () => { - await preloadData(jsx) - }) - - expect(store).toEqual({ - entity: { - 1: { - id: 1 - } - }, - __requests__: { - test: { - hello: 'hello world' - }, - test2: { - bonjour: 'bonjour le monde' - }, - test3: { - entity: [ '1' ] - } - } - }) - - expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() - }) + expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() + }) - test('preloadData should resolve promises based on its depth option', async () => { - const store = createStore({}) - const jsx = + test('preloadData should resolve promises based on its depth option', async () => { + const store = createStore({}) + const jsx = - + - await TestRenderer.act(async () => { - await preloadData(jsx, { depth: 1 }) - }) - expect(store).toEqual({ - __requests__: { - test: { - hello: 'hello world' - } - } - }) - expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() + await TestRenderer.act(async () => { + await preloadData(jsx, { depth: 1 }) + }) + expect(store).toEqual({ + __requests__: { + test: { + hello: 'hello world' + } + } }) + expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() + }) - test('preloadData should skip promises if the ssr option if false', async () => { - const store = createStore({}) - const jsx = + test('preloadData should skip promises if the ssr option if false', async () => { + const store = createStore({}) + const jsx = - + - await TestRenderer.act(async () => { - await preloadData(jsx) - }) - expect(store).toEqual({ - __requests__: {} - }) - expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() + await TestRenderer.act(async () => { + await preloadData(jsx) + }) + expect(store).toEqual({ + __requests__: {} }) + expect(TestRenderer.create(jsx).toJSON()).toMatchSnapshot() + }) - test('preloadData should propagate errors', async () => { - const store = createStore({}) - const jsx = + test('preloadData should propagate errors', async () => { + const store = createStore({}) + const jsx = - + - const jsx2 = + const jsx2 = - + - try { - await expect(preloadData(jsx)).rejects.toThrowError('rejected') - } catch(error) { - // silent - } - try { - await expect(preloadData(jsx2)).rejects.toThrowError('rejected') - } catch(error) { - // silent + try { + await expect(preloadData(jsx)).rejects.toThrowError('rejected') + } catch(error) { + // silent + } + try { + await expect(preloadData(jsx2)).rejects.toThrowError('rejected') + } catch(error) { + // silent + } + expect(store).toEqual({ + __requests__: { + test: { + hello: 'hello world' + }, + test2: { + bonjour: 'bonjour le monde' } - expect(store).toEqual({ - __requests__: { - test: { - hello: 'hello world' - }, - test2: { - bonjour: 'bonjour le monde' - } - } - }) + } }) + }) }) \ No newline at end of file diff --git a/test/react/hooks.test.js b/test/react/hooks.test.js index c5ab731..e4e61ab 100644 --- a/test/react/hooks.test.js +++ b/test/react/hooks.test.js @@ -6,604 +6,604 @@ import React from 'react' import wretch from 'wretch' import { normaliz } from 'normaliz' import { - render, - waitFor, - cleanup, - fireEvent + render, + waitFor, + cleanup, + fireEvent } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import { sleep } from './utils' import { - watch, - store as createStore, - useRequest, - useNormalizedRequest, - useResource, - setHooksDependencies + watch, + store as createStore, + useRequest, + useNormalizedRequest, + useResource, + setHooksDependencies } from '../../src/react' import { - normalizedOperations + normalizedOperations } from '../../src/http/tools' afterEach(cleanup) setHooksDependencies({ wretch, normaliz }) wretch().polyfills({ - fetch: require('node-fetch') + fetch: require('node-fetch') }) describe('React hooks test suite', () => { - describe('useRequest', () => { - it('should fetch data', async () => { - const store = {} - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - text() { - return Promise.resolve('text') - } - }) - ]) - const Component = () => { - const { loading, data } = useRequest( - '/text', - { - store, - client: fakeClient, - bodyType: 'text' - } - ) - - return ( - loading ? -
loading
: -
{ data }
- ) - } - - const { getByText } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByText('text')).toBeTruthy() - }) + describe('useRequest', () => { + it('should fetch data', async () => { + const store = {} + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + text() { + return Promise.resolve('text') + } }) + ]) + const Component = () => { + const { loading, data } = useRequest( + '/text', + { + store, + client: fakeClient, + bodyType: 'text' + } + ) + + return ( + loading ? +
loading
: +
{ data }
+ ) + } + + const { getByText } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByText('text')).toBeTruthy() + }) + }) - it('should throw if wretch errored', async () => { - const store = {} - const Component = () => { - const { loading, error } = useRequest( - 'error', - { - store - } - ) - - return ( - loading ? -
loading
: - error ? -
{ error.message }
: - null - ) - } - const { getByText } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByText('Only absolute URLs are supported')).toBeTruthy() - }) - }) + it('should throw if wretch errored', async () => { + const store = {} + const Component = () => { + const { loading, error } = useRequest( + 'error', + { + store + } + ) + + return ( + loading ? +
loading
: + error ? +
{ error.message }
: + null + ) + } + const { getByText } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByText('Only absolute URLs are supported')).toBeTruthy() + }) + }) - it('should fetch data from the network', async () => { - const store = createStore({}) - - let counter = 0 - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return ( - counter++ === 0 ? - { hello: 'hello world'} : - { hello: 'bonjour le monde'} - ) - } - }) - ]) - - const Component = watch(() => { - const { loading, data } = useRequest( - '/hello', - { - store, - client: fakeClient - } - ) - - const { loading: networkLoading, data: networkData } = useRequest( - '/hello', - { - store, - client: fakeClient, - skip: () => loading, - policy: 'network-only' - } - ) - - if(loading && !networkLoading) - return
loading…
- - if(!loading && networkLoading && data && !networkData) - return
{ data.hello }
- - if(data && networkData) - return
{ data.hello + ' ' + networkData.hello }
- - return null - }) - - const { getByText } = render() - expect(getByText('loading…')).toBeTruthy() - await waitFor(() => { - expect(getByText('hello world')).toBeTruthy() - }) - await waitFor(() => { - expect(getByText('bonjour le monde bonjour le monde')).toBeTruthy() - }) + it('should fetch data from the network', async () => { + const store = createStore({}) + + let counter = 0 + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return ( + counter++ === 0 ? + { hello: 'hello world'} : + { hello: 'bonjour le monde'} + ) + } }) + ]) + + const Component = watch(() => { + const { loading, data } = useRequest( + '/hello', + { + store, + client: fakeClient + } + ) + + const { loading: networkLoading, data: networkData } = useRequest( + '/hello', + { + store, + client: fakeClient, + skip: () => loading, + policy: 'network-only' + } + ) + + if(loading && !networkLoading) + return
loading…
+ + if(!loading && networkLoading && data && !networkData) + return
{ data.hello }
+ + if(data && networkData) + return
{ data.hello + ' ' + networkData.hello }
+ + return null + }) + + const { getByText } = render() + expect(getByText('loading…')).toBeTruthy() + await waitFor(() => { + expect(getByText('hello world')).toBeTruthy() + }) + await waitFor(() => { + expect(getByText('bonjour le monde bonjour le monde')).toBeTruthy() + }) }) - - const payload = { + }) + + const payload = { + id: 1, + title: 'My Item', + post: { id: 4, date: '01-01-1970' }, + users: [{ + userId: 1, + name: 'john' + }, { + userId: 2, + name: 'jane', + comments: [{ + id: 3, + subId: 1, + content: 'Hello' + }] + }] + } + + const normalizedPayload = { + items: { + 1: { id: 1, title: 'My Item', - post: { id: 4, date: '01-01-1970' }, - users: [{ - userId: 1, - name: 'john' - }, { - userId: 2, - name: 'jane', - comments: [{ - id: 3, - subId: 1, - content: 'Hello' - }] - }] - } - - const normalizedPayload = { - items: { - 1: { - id: 1, - title: 'My Item', - post: 4, - users: [ 1, 2 ] - } - }, - users: { - 1: { userId: 1, name: 'john' }, - 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } - }, - posts: { - 4: { id: 4, date: '01-01-1970' } - }, - comments: { - '3 - 1': { id: 3, subId: 1, content: 'Hello' } - }, - itemsContainer: { - container_1: { - items: 1 - } - } + post: 4, + users: [ 1, 2 ] + } + }, + users: { + 1: { userId: 1, name: 'john' }, + 2: { userId: 2, name: 'jane', comments: [ '3 - 1' ] } + }, + posts: { + 4: { id: 4, date: '01-01-1970' } + }, + comments: { + '3 - 1': { id: 3, subId: 1, content: 'Hello' } + }, + itemsContainer: { + container_1: { + items: 1 + } } - - const normalizeOptions = { - entity: 'items', - schema: [ - [ 'post', { mapping: 'posts' } ], - [ 'users', - [ - ['comments', { - key: comment => comment.id + ' - ' + comment.subId - }] - ], - { - key: 'userId' - } - ] + } + + const normalizeOptions = { + entity: 'items', + schema: [ + [ 'post', { mapping: 'posts' } ], + [ 'users', + [ + ['comments', { + key: comment => comment.id + ' - ' + comment.subId + }] ], - from: { - itemsContainer: 'container_1' + { + key: 'userId' } + ] + ], + from: { + itemsContainer: 'container_1' } + } + + describe('useNormalizedRequest', () => { + + it('should fetch data and normalize it', async () => { + const store = {} + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve(payload) + } + }) + ]) + const Component = () => { + const { loading, data } = useNormalizedRequest( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + return ( + loading ? +
loading
: +
{ JSON.stringify(data) }
+ ) + } + + const { getByText, getByTestId } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByTestId('stringified-data')).toBeTruthy() + }) + expect(JSON.parse(getByTestId('stringified-data').textContent)).toEqual(normalizedPayload) + }) - describe('useNormalizedRequest', () => { - - it('should fetch data and normalize it', async () => { - const store = {} - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve(payload) - } - }) - ]) - const Component = () => { - const { loading, data } = useNormalizedRequest( - '/item/1', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - return ( - loading ? -
loading
: -
{ JSON.stringify(data) }
- ) - } + it('should throw if wretch errored', async () => { + const store = {} + const Component = () => { + const { loading, error } = useNormalizedRequest( + 'error', + { store } + ) + + return ( + loading ? +
loading
: + error ? +
{ error.message }
: + null + ) + } + const { getByText } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByText('Only absolute URLs are supported')).toBeTruthy() + }) + }) - const { getByText, getByTestId } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByTestId('stringified-data')).toBeTruthy() - }) - expect(JSON.parse(getByTestId('stringified-data').textContent)).toEqual(normalizedPayload) + it('should fetch data from the network', async () => { + const store = createStore({}) + + let counter = 0 + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return ( + counter++ >= 1 ? + { + ...payload, + title: 'Updated Title' + } : + payload + ) + } }) - - it('should throw if wretch errored', async () => { - const store = {} - const Component = () => { - const { loading, error } = useNormalizedRequest( - 'error', - { store } - ) - - return ( - loading ? -
loading
: - error ? -
{ error.message }
: - null - ) - } - const { getByText } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByText('Only absolute URLs are supported')).toBeTruthy() - }) + ]) + + const Component = watch(() => { + const { loading, data } = useNormalizedRequest( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + const { loading: networkLoading, data: networkData } = useNormalizedRequest( + '/item/1', + { + store, + client: fakeClient, + normalize: normalizeOptions, + skip: () => loading, + policy: 'network-only' + } + ) + + if(loading && !networkLoading) + return
loading…
+ + if(!loading && networkLoading && data && !networkData) + return
{ data.items['1'].title }
+ + if(data && networkData) + return
{ data.items['1'].title + ' ' + networkData.items['1'].title }
+ + return null + }) + + const { getByText } = render() + expect(getByText('loading…')).toBeTruthy() + await waitFor(() => { + expect(getByText('My Item')).toBeTruthy() + }) + await waitFor(() => { + expect(getByText('Updated Title Updated Title')).toBeTruthy() + }) + }) + }) + + describe('useResource', () => { + + it('should fetch a single resource, normalize it and return the data', async () => { + const store = {} + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve(payload) + } }) + ]) + const Component = () => { + const { loading, data } = useResource( + 'items', + '/item/1', + { + id: 1, + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + return ( + loading ? +
loading
: +
{ JSON.stringify(data) }
+ ) + } + + const { getByText, getByTestId } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByTestId('data-item')).toBeTruthy() + }) + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual(normalizedPayload.items['1']) + }) - it('should fetch data from the network', async () => { - const store = createStore({}) - - let counter = 0 - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return ( - counter++ >= 1 ? - { - ...payload, - title: 'Updated Title' - } : - payload - ) - } - }) - ]) - - const Component = watch(() => { - const { loading, data } = useNormalizedRequest( - '/item/1', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - const { loading: networkLoading, data: networkData } = useNormalizedRequest( - '/item/1', - { - store, - client: fakeClient, - normalize: normalizeOptions, - skip: () => loading, - policy: 'network-only' - } - ) - - if(loading && !networkLoading) - return
loading…
- - if(!loading && networkLoading && data && !networkData) - return
{ data.items['1'].title }
- - if(data && networkData) - return
{ data.items['1'].title + ' ' + networkData.items['1'].title }
- - return null - }) - - const { getByText } = render() - expect(getByText('loading…')).toBeTruthy() - await waitFor(() => { - expect(getByText('My Item')).toBeTruthy() - }) - await waitFor(() => { - expect(getByText('Updated Title Updated Title')).toBeTruthy() - }) + it('should fetch multiple resources, normalize them and return the data', async () => { + const store = {} + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + json() { + return Promise.resolve([payload]) + } }) + ]) + const Component = () => { + const { loading, data } = useResource( + 'items', + '/items', + { + store, + client: fakeClient, + normalize: normalizeOptions + } + ) + + return ( + loading ? +
loading
: +
{ JSON.stringify(data) }
+ ) + } + + const { getByText, getByTestId } = render() + expect(getByText('loading')).toBeTruthy() + await waitFor(() => { + expect(getByTestId('data-item')).toBeTruthy() + }) + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual([normalizedPayload.items['1']]) }) - describe('useResource', () => { - - it('should fetch a single resource, normalize it and return the data', async () => { - const store = {} - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve(payload) - } - }) - ]) - const Component = () => { - const { loading, data } = useResource( - 'items', - '/item/1', - { - id: 1, - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - return ( - loading ? -
loading
: -
{ JSON.stringify(data) }
- ) + it('should retrieve data from the cache by id', async () => { + const store = createStore({ + item: { + 1: { + id: 1, + title: 'Title' + } + }, + __requests__: { + testKey: { + item: [1] + } + } + }) + + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return { + id: 1, + title: 'Updated title' } - - const { getByText, getByTestId } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByTestId('data-item')).toBeTruthy() - }) - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual(normalizedPayload.items['1']) + } }) - - it('should fetch multiple resources, normalize them and return the data', async () => { - const store = {} - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - json() { - return Promise.resolve([payload]) - } - }) - ]) - const Component = () => { - const { loading, data } = useResource( - 'items', - '/items', - { - store, - client: fakeClient, - normalize: normalizeOptions - } - ) - - return ( - loading ? -
loading
: -
{ JSON.stringify(data) }
- ) - } - - const { getByText, getByTestId } = render() - expect(getByText('loading')).toBeTruthy() - await waitFor(() => { - expect(getByTestId('data-item')).toBeTruthy() - }) - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual([normalizedPayload.items['1']]) + ]) + + const Component = () => { + const { data } = useResource( + 'item', + '/item/1', + { + id: 1, + store, + client: fakeClient, + serialize: () => 'testKey', + policy: 'cache-and-network' + } + ) + + return ( + !data ? +
No data in the cache
: +
{ data && JSON.stringify(data) }
+ ) + } + + const { getByTestId } = render() + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ + id: 1, + title: 'Title' + }) + await waitFor(() => { + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ + id: 1, + title: 'Updated title' }) + }) + }) - it('should retrieve data from the cache by id', async () => { - const store = createStore({ - item: { - 1: { - id: 1, - title: 'Title' - } - }, - __requests__: { - testKey: { - item: [1] - } - } - }) - - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return { - id: 1, - title: 'Updated title' - } - } - }) - ]) - - const Component = () => { - const { data } = useResource( - 'item', - '/item/1', - { - id: 1, - store, - client: fakeClient, - serialize: () => 'testKey', - policy: 'cache-and-network' - } - ) - - return ( - !data ? -
No data in the cache
: -
{ data && JSON.stringify(data) }
- ) + it('should refetch data properly', async () => { + const store = createStore({ + item: { + 1: { + id: 1, + title: 'Title' + } + }, + __requests__: { + testKey: { + item: [1] + } + } + }) + + const fakeClient = wretch().middlewares([ + () => () => Promise.resolve({ + ok: true, + async json() { + await sleep() + return { + id: 1, + title: 'Updated title' } - - const { getByTestId } = render() - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ - id: 1, - title: 'Title' - }) - await waitFor(() => { - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ - id: 1, - title: 'Updated title' - }) - }) + } }) - - it('should refetch data properly', async () => { - const store = createStore({ - item: { - 1: { - id: 1, - title: 'Title' - } - }, - __requests__: { - testKey: { - item: [1] - } - } - }) - - const fakeClient = wretch().middlewares([ - () => () => Promise.resolve({ - ok: true, - async json() { - await sleep() - return { - id: 1, - title: 'Updated title' - } - } - }) - ]) - - const Component = () => { - const { data, refetch } = useResource( - 'item', - '/item/1', - { - id: 1, - store, - client: fakeClient, - serialize: () => 'testKey', - policy: 'cache-first' - } - ) - - return ( - <> - { !data ? -
No data in the cache
: -
{ data && JSON.stringify(data) }
- } - - - ) + ]) + + const Component = () => { + const { data, refetch } = useResource( + 'item', + '/item/1', + { + id: 1, + store, + client: fakeClient, + serialize: () => 'testKey', + policy: 'cache-first' + } + ) + + return ( + <> + { !data ? +
No data in the cache
: +
{ data && JSON.stringify(data) }
} + + + ) + } - const { getByTestId } = render() - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ - id: 1, - title: 'Title' - }) - fireEvent.click(getByTestId('refetch-button')) - await waitFor(() => { - expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ - id: 1, - title: 'Updated title' - }) - }) + const { getByTestId } = render() + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ + id: 1, + title: 'Title' + }) + fireEvent.click(getByTestId('refetch-button')) + await waitFor(() => { + expect(JSON.parse(getByTestId('data-item').textContent)).toEqual({ + id: 1, + title: 'Updated title' }) + }) }) + }) - describe('Hooks tools', () => { - it('should properly read data from the store using mappings', () => { - const store = { - items: { - 1: { - id: 1, - list: [1, 2] - }, - 2: '…' - } - } - const mappings = { - items: [ 1, 2 ], - none: [ 1 ] - } - const storeFragment = normalizedOperations.read(mappings, store) - expect(storeFragment).toEqual({ - items: { - 1: { - id: 1, - list: [1, 2] - }, - 2: '…' - }, - none: { - 1: null - } - }) - }) - it('should write normalized data into the store', () => { - const store = { - items: { - 1: { - id: 1, - list: [1, 2] - }, - 2: '…' - } - } - normalizedOperations.write({ - items: { - 1: { - id: 1, - number: 1 - }, - 2: '!== object' - } - }, store) - - expect(store).toEqual({ - items: { - 1: { - id: 1, - list: [1, 2], - number: 1 - }, - 2: '!== object' - } - }) - }) + describe('Hooks tools', () => { + it('should properly read data from the store using mappings', () => { + const store = { + items: { + 1: { + id: 1, + list: [1, 2] + }, + 2: '…' + } + } + const mappings = { + items: [ 1, 2 ], + none: [ 1 ] + } + const storeFragment = normalizedOperations.read(mappings, store) + expect(storeFragment).toEqual({ + items: { + 1: { + id: 1, + list: [1, 2] + }, + 2: '…' + }, + none: { + 1: null + } + }) + }) + it('should write normalized data into the store', () => { + const store = { + items: { + 1: { + id: 1, + list: [1, 2] + }, + 2: '…' + } + } + normalizedOperations.write({ + items: { + 1: { + id: 1, + number: 1 + }, + 2: '!== object' + } + }, store) + + expect(store).toEqual({ + items: { + 1: { + id: 1, + list: [1, 2], + number: 1 + }, + 2: '!== object' + } + }) }) + }) }) \ No newline at end of file diff --git a/test/react/utils.js b/test/react/utils.js index 3a1decd..eacb0ea 100644 --- a/test/react/utils.js +++ b/test/react/utils.js @@ -1,5 +1,5 @@ export function sleep(ms = 250) { - return new Promise(resolve => { - setTimeout(resolve, ms) - }) + return new Promise(resolve => { + setTimeout(resolve, ms) + }) } diff --git a/test/websocket.test.js b/test/websocket.test.js index 6dea672..59c807b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1,120 +1,120 @@ const WebSocket = require('ws') const { - server: hyperactivServer, - client: hyperactivClient + server: hyperactivServer, + client: hyperactivClient } = require('../src/websocket/server').default let wss = null let hostedObject = null const clientObjects = { - one: {}, - two: {} + one: {}, + two: {} } function getClientReflection(hostedObject) { - if(hostedObject instanceof Array) { - return hostedObject - .filter(_ => typeof _ !== 'function') - .map(getClientReflection) - } else if(typeof hostedObject === 'object') { - const reflection = {} - Object.entries(hostedObject).forEach(([ key, value ]) => { - if(typeof value !== 'function') - reflection[key] = getClientReflection(value) - }) - return reflection - } - + if(hostedObject instanceof Array) { return hostedObject + .filter(_ => typeof _ !== 'function') + .map(getClientReflection) + } else if(typeof hostedObject === 'object') { + const reflection = {} + Object.entries(hostedObject).forEach(([ key, value ]) => { + if(typeof value !== 'function') + reflection[key] = getClientReflection(value) + }) + return reflection + } + + return hostedObject } const sleep = (time = 250) => new Promise(resolve => setTimeout(resolve, time)) beforeAll(async () => { - wss = hyperactivServer(new WebSocket.Server({ port: 8080 })) - await sleep() + wss = hyperactivServer(new WebSocket.Server({ port: 8080 })) + await sleep() }, 5000) test('Host server side without argument', async () => { - const wss = new WebSocket.Server({ port: 8081 }) - const generatedHostedObject = hyperactivServer(wss).host() - await sleep() - expect(generatedHostedObject).toStrictEqual({}) - wss.close() + const wss = new WebSocket.Server({ port: 8081 }) + const generatedHostedObject = hyperactivServer(wss).host() + await sleep() + expect(generatedHostedObject).toStrictEqual({}) + wss.close() }) test('Host an object server side', async () => { - const baseObject = { - a: 1, - getA: function() { - return hostedObject.a - }, - __remoteMethods: 'getA', - nested: { - b: 2, - getB() { return hostedObject.nested.b }, - getBPlus(number) { return hostedObject.nested.b + number }, - getError() { - throw new Error('bleh') - }, - __remoteMethods: [ - 'getB', - 'getBPlus', - 'getError' - ] - } + const baseObject = { + a: 1, + getA: function() { + return hostedObject.a + }, + __remoteMethods: 'getA', + nested: { + b: 2, + getB() { return hostedObject.nested.b }, + getBPlus(number) { return hostedObject.nested.b + number }, + getError() { + throw new Error('bleh') + }, + __remoteMethods: [ + 'getB', + 'getBPlus', + 'getError' + ] } - hostedObject = wss.host(baseObject) - await sleep() - expect(hostedObject).toStrictEqual(baseObject) + } + hostedObject = wss.host(baseObject) + await sleep() + expect(hostedObject).toStrictEqual(baseObject) }) test('Sync the object client side', async () => { - clientObjects.one = hyperactivClient(new WebSocket('ws://localhost:8080')) - await sleep() - expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) + clientObjects.one = hyperactivClient(new WebSocket('ws://localhost:8080')) + await sleep() + expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) }) test('Sync another object client side', async () => { - hyperactivClient(new WebSocket('ws://localhost:8080'), clientObjects.two) - await sleep() - expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) + hyperactivClient(new WebSocket('ws://localhost:8080'), clientObjects.two) + await sleep() + expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) }) test('Mutate server should update client', async () => { - hostedObject.a = 2 - hostedObject.a2 = 1 - await sleep() - expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) - expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) + hostedObject.a = 2 + hostedObject.a2 = 1 + await sleep() + expect(clientObjects.one).toMatchObject(getClientReflection(hostedObject)) + expect(clientObjects.two).toMatchObject(getClientReflection(hostedObject)) }) test('Call remote functions', async () => { - const a = await clientObjects.one.getA() - expect(a).toBe(2) - const b = await clientObjects.two.nested.getB() - expect(b).toBe(2) - const bPlusOne = await clientObjects.two.nested.getBPlus(5) - expect(bPlusOne).toBe(7) - return expect(clientObjects.one.nested.getError()).rejects.toMatch('bleh') + const a = await clientObjects.one.getA() + expect(a).toBe(2) + const b = await clientObjects.two.nested.getB() + expect(b).toBe(2) + const bPlusOne = await clientObjects.two.nested.getBPlus(5) + expect(bPlusOne).toBe(7) + return expect(clientObjects.one.nested.getError()).rejects.toMatch('bleh') }) test('autoExportMethods should declare remote methods automatically', async () => { - const wss = new WebSocket.Server({ port: 8081 }) - const baseObj = { a: 1, getA() { return baseObj.a }} - const hosted = hyperactivServer(wss).host(baseObj, { autoExportMethods: true }) - await sleep() - const client = hyperactivClient(new WebSocket('ws://localhost:8081')) - await sleep() - let a = await client.getA() - expect(a).toBe(1) - hosted.a = 2 - await sleep() - a = await client.getA() - expect(a).toBe(2) - wss.close() + const wss = new WebSocket.Server({ port: 8081 }) + const baseObj = { a: 1, getA() { return baseObj.a }} + const hosted = hyperactivServer(wss).host(baseObj, { autoExportMethods: true }) + await sleep() + const client = hyperactivClient(new WebSocket('ws://localhost:8081')) + await sleep() + let a = await client.getA() + expect(a).toBe(1) + hosted.a = 2 + await sleep() + a = await client.getA() + expect(a).toBe(2) + wss.close() }) afterAll(() => { - wss.close() + wss.close() })