├── .npmignore ├── .prettierrc.json ├── .gitignore ├── .travis.yml ├── jest.transform.js ├── __tests__ ├── mobx-state-tree │ ├── 1.2.1 │ │ ├── package.json │ │ └── middleware.js │ ├── 1.3.1 │ │ ├── package.json │ │ └── middleware.js │ ├── 2.0.1 │ │ ├── package.json │ │ └── middleware.js │ └── 1.4.0 │ │ ├── package.json │ │ ├── yarn.lock │ │ └── middleware.js ├── index.js ├── utils.js ├── tracker.js └── middleware.js ├── src ├── track.js ├── utils.js ├── index.js ├── middleware.js └── tracker.js ├── jest.setup.js ├── .eslintrc.json ├── package.json ├── README.md └── webpack.config.js /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | __tests__/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .DS_Store 4 | /lib 5 | __tests__/**/package-lock.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | yarn: true 4 | script: yarn run test 5 | node_js: 6 | - "6.10.1" 7 | -------------------------------------------------------------------------------- /jest.transform.js: -------------------------------------------------------------------------------- 1 | module.exports = require('babel-jest').createTransformer({ presets: [ ['env', { targets: { "node": "current" } }] ] }) 2 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.2.1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "mobx-state-tree": "1.2.1", 4 | "mobx": "^3.1.15" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.3.1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "mobx-state-tree": "1.3.1", 4 | "mobx": "^3.1.15" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/2.0.1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "mobx-state-tree": "2.0.1", 4 | "mobx": "^4.1.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.4.0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "mobx-state-tree": "^1.4.0", 4 | "mobx": "^3.1.15" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/track.js: -------------------------------------------------------------------------------- 1 | const track = (model, config) => { 2 | return model.volatile(() => ({ 3 | $arboris: config 4 | })) 5 | } 6 | 7 | track.async = (opts = {}) => Object.assign({}, { type: "async" }, opts) 8 | 9 | export default track 10 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function makePromise() { 2 | let resolve, reject 3 | const promise = new Promise(function(resolveFn, rejectFn) { 4 | resolve = resolveFn 5 | reject = rejectFn 6 | }) 7 | promise.resolve = resolve 8 | promise.reject = reject 9 | return promise 10 | } 11 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const execSync = require("child_process").execSync 2 | const mstVersions = ["1.2.1", "1.3.1", "1.4.0", "2.0.1"] 3 | 4 | mstVersions.forEach(function(version) { 5 | execSync( 6 | `cd __tests__/mobx-state-tree/${version} && npm install --silent`, 7 | error => { 8 | if (error !== null) { 9 | console.log(`exec error: ${error}`) 10 | } 11 | } 12 | ) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.4.0/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | mobx-state-tree@^1.4.0: 6 | version "1.4.0" 7 | resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-1.4.0.tgz#c914c855d5ec5c1c16e4ba6d6925679df42c8110" 8 | 9 | mobx@^3.1.15: 10 | version "3.6.2" 11 | resolved "https://registry.yarnpkg.com/mobx/-/mobx-3.6.2.tgz#fb9f5ff5090539a1ad54e75dc4c098b602693320" 12 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | import index from '../src/index' 2 | 3 | describe('index.js', () => { 4 | it('returns a function', () => { 5 | expect(index).toBeInstanceOf(Function); 6 | }) 7 | 8 | it('returns a function that returns middleware', () => { 9 | const arboris = index() 10 | expect(arboris.middleware).toBeInstanceOf(Function) 11 | }) 12 | 13 | it('returns a function that retuns a render function', () => { 14 | const arboris = index() 15 | expect(arboris.render).toBeInstanceOf(Function) 16 | }) 17 | }) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server" 2 | import Tracker from "./tracker" 3 | import createMiddleware from "./middleware" 4 | 5 | function Arboris({ 6 | renderLimit = 10, 7 | warnOnMaxLimit = true, 8 | timeLimit = 25000, 9 | logger = console, 10 | renderMethod = markup => renderToString(markup) 11 | } = {}) { 12 | const tracker = new Tracker({ 13 | renderLimit, 14 | timeLimit, 15 | warnOnMaxLimit, 16 | logger 17 | }) 18 | 19 | return { 20 | middleware: createMiddleware(tracker, logger), 21 | render: async markup => tracker.wait(() => renderMethod(markup)) 22 | } 23 | } 24 | 25 | export default Arboris 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:jest/recommended", 5 | "plugin:import/errors", 6 | "plugin:import/warnings", 7 | "prettier" 8 | ], 9 | "env": { 10 | "browser": true, 11 | "node": true, 12 | "jest/globals": true, 13 | "es6": true 14 | }, 15 | "parser": "babel-eslint", 16 | "parserOptions": { 17 | "ecmaVersion": 8, 18 | "ecmaFeatures": { 19 | "jsx": true, 20 | "modules": true 21 | } 22 | }, 23 | "plugins": ["jest"], 24 | "rules": { 25 | "strict": [2, "never"], 26 | "semi": [2, "never"], 27 | "no-console": 0, 28 | "no-debugger": 0 29 | }, 30 | "settings": { 31 | "import/resolver": { 32 | "webpack": { 33 | "config": { 34 | "resolve": { 35 | "modules": [ 36 | "src", 37 | "node_modules" 38 | ], 39 | "extensions": [ 40 | ".js", 41 | ".jsx" 42 | ] 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | const getArborisOpts = call => 2 | (call.context && call.context.$arboris && call.context.$arboris[call.name]) || 3 | null 4 | 5 | export default function createMiddleware(tracker, logger) { 6 | return function(call, next, abort) { 7 | const opts = getArborisOpts(call) 8 | 9 | if (opts && opts.type === "async" && tracker.renderLimitReached()) { 10 | return typeof abort === "function" ? abort(null) : null 11 | } 12 | 13 | if (call.type === "flow_spawn") { 14 | tracker.add(call.id, call.name) 15 | } 16 | 17 | if (call.type === "flow_return" || call.type === "flow_throw") { 18 | if (!tracker.has(call.id)) { 19 | logger.error( 20 | "[arboris] Could not find flow " + 21 | call.name + 22 | " (" + 23 | call.type + 24 | ").\n" + 25 | "Make sure you're applying Arboris middleware before running any actions." 26 | ) 27 | return typeof abort === "function" ? abort(null) : null 28 | } else { 29 | tracker.remove(call.id) 30 | } 31 | } 32 | 33 | return next(call) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import {makePromise} from '../src/utils' 2 | 3 | describe('makePromise', () => { 4 | describe('resolve', () => { 5 | it('returns a promise with accessible resolve function', () => { 6 | const promise = makePromise() 7 | 8 | expect(promise).toBeInstanceOf(Promise) 9 | expect(promise.resolve).toBeInstanceOf(Function) 10 | }) 11 | 12 | it('resolves a promise', done => { 13 | const promise = makePromise() 14 | promise.then(value => { 15 | expect(value).toEqual(123) 16 | done() 17 | }) 18 | 19 | promise.resolve(123) 20 | }) 21 | }) 22 | 23 | describe('reject', () => { 24 | it('returns a promise with accessible reject function', () => { 25 | const promise = makePromise() 26 | 27 | expect(promise).toBeInstanceOf(Promise) 28 | expect(promise.reject).toBeInstanceOf(Function) 29 | }) 30 | 31 | it('rejects a promise', done => { 32 | const promise = makePromise() 33 | promise.then(null, value => { 34 | expect(value).toEqual(123) 35 | done() 36 | }) 37 | 38 | promise.reject(123) 39 | }) 40 | }) 41 | }) -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.2.1/middleware.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon" 2 | import createMiddleware from "../../../src/middleware" 3 | import track from "../../../src/track" 4 | import { addMiddleware, flow, types } from "./node_modules/mobx-state-tree" 5 | 6 | const trueFn = () => true 7 | 8 | let sandbox = sinon.createSandbox() 9 | 10 | describe("middleware.js", () => { 11 | afterEach(() => sandbox.restore()) 12 | 13 | describe("on MST 1.2.1", () => { 14 | describe("when renderLimit is reached", () => { 15 | it("fails with `false` value", async () => { 16 | const delay = sandbox.stub().resolves(true) 17 | const Store = track( 18 | types.model("Store").actions(() => ({ 19 | exampleFlow: flow(function*() { 20 | yield delay() 21 | return 2 22 | }) 23 | })), 24 | { exampleFlow: track.async() } 25 | ) 26 | 27 | const store = Store.create() 28 | 29 | const tracker = { 30 | add: sandbox.spy(), 31 | has: trueFn, 32 | remove: sandbox.spy(), 33 | renderLimitReached: trueFn 34 | } 35 | 36 | const middleware = createMiddleware(tracker, console) 37 | 38 | addMiddleware(store, middleware) 39 | 40 | const foo = await store.exampleFlow() 41 | expect(foo).toBe(null) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.3.1/middleware.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon" 2 | import createMiddleware from "../../../src/middleware" 3 | import track from "../../../src/track" 4 | import { addMiddleware, flow, types } from "./node_modules/mobx-state-tree" 5 | 6 | const trueFn = () => true 7 | 8 | let sandbox = sinon.createSandbox() 9 | 10 | describe("middleware.js", () => { 11 | afterEach(() => sandbox.restore()) 12 | 13 | describe("on MST 1.3.1", () => { 14 | describe("when renderLimit is reached", () => { 15 | it("fails with `false` value", async () => { 16 | const delay = sandbox.stub().resolves(true) 17 | const Store = track( 18 | types.model("Store").actions(() => ({ 19 | exampleFlow: flow(function*() { 20 | yield delay() 21 | return 2 22 | }) 23 | })), 24 | { exampleFlow: track.async() } 25 | ) 26 | 27 | const store = Store.create() 28 | 29 | const tracker = { 30 | add: sandbox.spy(), 31 | has: trueFn, 32 | remove: sandbox.spy(), 33 | renderLimitReached: trueFn 34 | } 35 | 36 | const middleware = createMiddleware(tracker, console) 37 | 38 | addMiddleware(store, middleware) 39 | 40 | const foo = await store.exampleFlow() 41 | expect(foo).toBe(null) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/1.4.0/middleware.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon" 2 | import createMiddleware from "../../../src/middleware" 3 | import track from "../../../src/track" 4 | import { addMiddleware, flow, types } from "./node_modules/mobx-state-tree" 5 | 6 | const trueFn = () => true 7 | 8 | let sandbox = sinon.createSandbox() 9 | 10 | describe("middleware.js", () => { 11 | afterEach(() => sandbox.restore()) 12 | 13 | describe("on MST 1.4.0", () => { 14 | describe("when renderLimit is reached", () => { 15 | it("fails using MST `abort` function", async () => { 16 | const delay = sandbox.stub().resolves(true) 17 | const Store = track( 18 | types.model("Store").actions(() => ({ 19 | exampleFlow: flow(function*() { 20 | yield delay() 21 | return 2 22 | }) 23 | })), 24 | { exampleFlow: track.async() } 25 | ) 26 | 27 | const store = Store.create() 28 | 29 | const tracker = { 30 | add: sandbox.spy(), 31 | has: trueFn, 32 | remove: sandbox.spy(), 33 | renderLimitReached: trueFn 34 | } 35 | 36 | const middleware = createMiddleware(tracker, console) 37 | 38 | addMiddleware(store, middleware) 39 | 40 | const foo = await store.exampleFlow() 41 | expect(foo).toBe(null) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /__tests__/mobx-state-tree/2.0.1/middleware.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon" 2 | import createMiddleware from "../../../src/middleware" 3 | import track from "../../../src/track" 4 | import { addMiddleware, flow, types } from "./node_modules/mobx-state-tree" 5 | 6 | const trueFn = () => true 7 | 8 | let sandbox = sinon.createSandbox() 9 | 10 | describe("middleware.js", () => { 11 | afterEach(() => sandbox.restore()) 12 | 13 | describe("on MST 2.0.1", () => { 14 | describe("when renderLimit is reached", () => { 15 | it("fails using MST `abort` function", async () => { 16 | const delay = sandbox.stub().resolves(true) 17 | const Store = track( 18 | types.model("Store").actions(() => ({ 19 | exampleFlow: flow(function*() { 20 | yield delay() 21 | return 2 22 | }) 23 | })), 24 | { exampleFlow: track.async() } 25 | ) 26 | 27 | const store = Store.create() 28 | 29 | const tracker = { 30 | add: sandbox.spy(), 31 | has: trueFn, 32 | remove: sandbox.spy(), 33 | renderLimitReached: trueFn 34 | } 35 | 36 | const middleware = createMiddleware(tracker, console) 37 | 38 | addMiddleware(store, middleware) 39 | 40 | const foo = await store.exampleFlow() 41 | expect(foo).toBe(null) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arboris", 3 | "version": "1.0.2", 4 | "description": "Hassle-free combination of mobx-state-tree and server-side rendering.", 5 | "main": "lib/index.js", 6 | "browser": "lib/index.web.js", 7 | "repository": "https://github.com/d4rky-pl/arboris", 8 | "author": "Michał Matyas", 9 | "license": "MIT", 10 | "scripts": { 11 | "prepare": "webpack", 12 | "test": "node jest.setup.js && jest", 13 | "test-debug": "node --inspect-brk ./node_modules/.bin/jest -i", 14 | "precommit": "lint-staged" 15 | }, 16 | "lint-staged": { 17 | "*.js": [ 18 | "prettier --write", 19 | "eslint --fix", 20 | "git add" 21 | ] 22 | }, 23 | "jest": { 24 | "transform": { 25 | "^.+\\.js$": "/jest.transform.js" 26 | } 27 | }, 28 | "peerDependencies": { 29 | "mobx-state-tree": ">= 1.2.1", 30 | "react": ">= 15", 31 | "react-dom": ">= 15" 32 | }, 33 | "dependencies": { 34 | "babel-runtime": "^6.26.0", 35 | "detect-node": "^2.0.3" 36 | }, 37 | "devDependencies": { 38 | "babel-core": "^6.26.0", 39 | "babel-eslint": "^8.0.1", 40 | "babel-jest": "^22.1.0", 41 | "babel-loader": "^7.1.2", 42 | "babel-plugin-transform-decorators-legacy": "^1.3.5", 43 | "babel-plugin-transform-runtime": "^6.23.0", 44 | "babel-preset-env": "^1.6.1", 45 | "eslint": "^4.10.0", 46 | "eslint-config-prettier": "2.9.0", 47 | "eslint-import-resolver-webpack": "^0.8.3", 48 | "eslint-loader": "^1.9.0", 49 | "eslint-plugin-import": "^2.8.0", 50 | "eslint-plugin-jest": "21.14.0", 51 | "eslint-plugin-prettier": "2.6.0", 52 | "husky": "0.14.3", 53 | "jest": "^22.1.4", 54 | "lint-staged": "7.0.0", 55 | "lodash.merge": "^4.6.0", 56 | "mobx": "^3.4.1", 57 | "mobx-state-tree": ">= 1.2.1", 58 | "prettier": "1.11.1", 59 | "react": ">= 15", 60 | "react-dom": ">= 15", 61 | "sinon": "^4.2.1", 62 | "webpack": "^3.8.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arboris 2 | 3 | Hassle-free combination of mobx-state-tree and server-side rendering. 4 | 5 | ## What it is 6 | 7 | This library aims to simplify adding server-side rendering to your React application by using the power of [mobx-state-tree](https://github.com/mobxjs/mobx-state-tree). 8 | 9 | It works by wrapping all asynchronous actions in tracking function and running your React components through initial rendering phase (prerendering). After the prerendering, Arboris will wait for all Promises to resolve and render your application once again, this time filled with all necessary data in store. Magic! 10 | 11 | Arboris does not aim to solve the server-side rendering configuration prerequisites, you still need to figure out how to compile your server and client assets with webpack. [We recommend Razzle for this](https://github.com/jaredpalmer/razzle). 12 | 13 | ## Installation 14 | 15 | run `yarn add arboris` or `npm install arboris`, depending on package manager you use. Easy peasy. 16 | 17 | ## Usage 18 | 19 | See [getting started](https://github.com/d4rky-pl/arboris/wiki/Getting-started) for step-by-step guide into implementing Arboris in your app. 20 | 21 | ## Powered by Arboris 22 | 23 | Arboris is maintained by developers at [Untitled Kingdom](https://untitledkingdom.com). 24 | Here are some projects, where you can see MST in action: 25 | 26 | 27 | 28 | ## Roadmap 29 | 30 | See [issues](https://github.com/d4rky-pl/arboris/issues). 31 | Let us know about any issues you encounter and ideas you would like to see in next releases. 32 | 33 | ## Testing 34 | 35 | Just run `yarn test` or `npm test`. 36 | 37 | Arboris is being testing against various MST versions. `jest.setup.js` script is taking care of installing it locally when running tests. 38 | If you want to add MST-dependent tests, add them to `__tests__/mobx-state-tree/[mstVersion]` directory and update `jest.setup.js` file. 39 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const merge = require("lodash.merge") 3 | 4 | const nodeBabel = { 5 | loader: "babel-loader", 6 | options: { 7 | presets: [ 8 | [ 9 | "env", 10 | { targets: { node: "current", browsers: "last 2 versions, ie >= 11" } } 11 | ] 12 | ], 13 | plugins: ["transform-runtime", "transform-decorators-legacy"] 14 | } 15 | } 16 | 17 | const browserBabel = { 18 | loader: "babel-loader", 19 | options: { 20 | presets: [["env", { targets: { browsers: "last 2 versions, ie >= 11" } }]], 21 | plugins: ["transform-runtime", "transform-decorators-legacy"] 22 | } 23 | } 24 | 25 | const defaultConfig = { 26 | devtool: "source-map", 27 | output: { 28 | path: __dirname, 29 | library: "arboris", 30 | libraryTarget: "umd", 31 | umdNamedDefine: true 32 | }, 33 | externals: { 34 | "mobx-state-tree": "mobx-state-tree", 35 | "react-dom/server": "react-dom/server" 36 | }, 37 | module: { 38 | rules: [ 39 | { 40 | test: /(\.js)$/, 41 | exclude: /node_modules/, 42 | use: nodeBabel 43 | }, 44 | { 45 | test: /src\/(\.js)$/, 46 | loader: "eslint-loader", 47 | exclude: /node_modules/ 48 | } 49 | ] 50 | }, 51 | resolve: { 52 | modules: [path.resolve("./node_modules"), path.resolve("./src")], 53 | extensions: [".json", ".js"] 54 | } 55 | } 56 | 57 | const makeConfig = function(entry, target) { 58 | const newConfig = merge({}, defaultConfig) 59 | newConfig.entry = `${__dirname}/src/${entry}.js` 60 | newConfig.target = target 61 | 62 | if (target === "node") { 63 | newConfig.output.filename = `lib/${entry}.js` 64 | newConfig.module.rules[0].use = nodeBabel 65 | } else { 66 | newConfig.output.filename = `lib/${entry}.${target}.js` 67 | newConfig.module.rules[0].use = browserBabel 68 | } 69 | 70 | return newConfig 71 | } 72 | 73 | module.exports = [ 74 | makeConfig("index", "node"), 75 | makeConfig("index", "web"), 76 | makeConfig("track", "node"), 77 | makeConfig("track", "web") 78 | ] 79 | -------------------------------------------------------------------------------- /src/tracker.js: -------------------------------------------------------------------------------- 1 | import { makePromise } from "./utils" 2 | 3 | const TIMEOUT = new Error("Tracker timeout") 4 | 5 | class Tracker { 6 | constructor({ renderLimit, timeLimit, warnOnMaxLimit, logger }) { 7 | this.collection = new Map() 8 | 9 | this.renderLimit = renderLimit 10 | this.renderCycle = 1 11 | this.timeLimit = timeLimit 12 | this.warnOnMaxLimit = warnOnMaxLimit 13 | this.logger = logger 14 | } 15 | 16 | add(obj, label) { 17 | if (typeof label === "undefined") { 18 | if (typeof obj.name !== "undefined" && obj.name !== "") { 19 | label = obj.name 20 | } else { 21 | label = obj.toString() 22 | } 23 | } 24 | 25 | this.collection.set(obj, label) 26 | } 27 | 28 | remove(obj) { 29 | this.collection.delete(obj) 30 | if (this.collection.size === 0 && this.promise) { 31 | this.promise.resolve() 32 | } 33 | } 34 | 35 | has(obj) { 36 | return this.collection.has(obj) 37 | } 38 | 39 | renderLimitReached() { 40 | return this.renderCycle >= this.renderLimit 41 | } 42 | 43 | unfinished() { 44 | return Array.from(this.collection.values()) 45 | } 46 | 47 | async wait(renderMethod) { 48 | let timeLeft = this.timeLimit 49 | let currentTime = Date.now() 50 | let result 51 | 52 | try { 53 | while (!this.renderLimitReached()) { 54 | this.promise = makePromise() 55 | let timeout = setTimeout(() => this.promise.reject(TIMEOUT), timeLeft) 56 | 57 | result = renderMethod() 58 | 59 | if (this.collection.size > 0) { 60 | await this.promise 61 | 62 | clearTimeout(timeout) 63 | timeLeft = timeLeft - Math.round((currentTime - Date.now()) / 1000) 64 | 65 | if (timeLeft > 0) { 66 | currentTime = Date.now() 67 | this.renderCycle += 1 68 | } else { 69 | throw TIMEOUT 70 | } 71 | } else { 72 | clearTimeout(timeout) 73 | break 74 | } 75 | } 76 | if (this.warnOnMaxLimit && this.renderLimitReached()) { 77 | this.logger.warn( 78 | "[arboris] Render limit has been hit on this request and yet there are still unfinished flows and methods.\n" + 79 | "Double-check if you're not falling into an infinite loop.\n\n" + 80 | "Unfinished flows and methods:\n" + 81 | this.unfinished().join("\n") 82 | ) 83 | } 84 | } catch (e) { 85 | if (e === TIMEOUT) { 86 | this.logger.error( 87 | "[arboris] Asynchronous methods have not finished in " + 88 | this.timeLimit + 89 | "ms.\n" + 90 | "Make sure that all Promises and flows are resolved to prevent memory leaks.\n\n" + 91 | "Unfinished flows and methods:\n" + 92 | this.unfinished().join("\n") 93 | ) 94 | } else { 95 | this.logger.error( 96 | "[arboris] Your store has thrown an uncaught exception.\n" + 97 | "Make sure you're catching and handling all exceptions properly." 98 | ) 99 | throw e 100 | } 101 | } 102 | 103 | return result 104 | } 105 | } 106 | 107 | Tracker.TIMEOUT = TIMEOUT 108 | export default Tracker 109 | -------------------------------------------------------------------------------- /__tests__/tracker.js: -------------------------------------------------------------------------------- 1 | import Tracker from '../src/tracker' 2 | import { makePromise } from '../src/utils' 3 | import sinon from 'sinon' 4 | 5 | const fn = () => {} 6 | const name = Date.now() 7 | 8 | const sandbox = sinon.createSandbox() 9 | const newTracker = ({ renderLimit = 10, timeLimit = 25000, warnOnMaxLimit = true, logger = console } = {}) => 10 | new Tracker({ renderLimit, timeLimit, warnOnMaxLimit, logger }) 11 | 12 | describe('tracker.js', () => { 13 | afterEach(() => sandbox.restore()) 14 | 15 | it('adds call to collection', () => { 16 | const tracker = newTracker() 17 | 18 | tracker.add(fn, name) 19 | 20 | expect(tracker.collection.size).toBe(1) 21 | }) 22 | 23 | it('adds unlabeled call to collection', () => { 24 | const tracker = newTracker() 25 | 26 | tracker.add(fn) 27 | 28 | expect(tracker.collection.values().next().value).toBe(fn.name) 29 | expect(tracker.collection.size).toBe(1) 30 | }) 31 | 32 | it('adds unlabeled ananymous function as a call to collection', () => { 33 | const tracker = newTracker() 34 | 35 | tracker.add(() => {}) 36 | 37 | expect(tracker.collection.values().next().value).toBe('() => {}') 38 | expect(tracker.collection.size).toBe(1) 39 | }) 40 | 41 | it('removes call from collection if there is one', () => { 42 | const tracker = newTracker() 43 | 44 | tracker.collection.set(fn, name) 45 | 46 | expect(tracker.collection.size).toBe(1) 47 | 48 | tracker.remove(fn) 49 | 50 | expect(tracker.collection.size).toBe(0) 51 | }) 52 | 53 | it('resolves the promise if there is no more calls to remove', () => { 54 | const tracker = newTracker() 55 | tracker.promise = makePromise() 56 | tracker.promise.resolve = sandbox.spy() 57 | 58 | expect(tracker.collection.size).toBe(0) 59 | 60 | tracker.remove(fn) 61 | 62 | expect(tracker.promise.resolve.calledOnce).toBe(true) 63 | }) 64 | 65 | it('checks if call is in collection', () => { 66 | const tracker = newTracker() 67 | tracker.collection.set(fn, name) 68 | 69 | expect(tracker.has(fn)).toBe(true) 70 | }) 71 | 72 | it('successfully waits for render loop to finish on first pass', async () => { 73 | const logger = { error: sandbox.spy() } 74 | const tracker = newTracker({ logger }) 75 | tracker.add(fn) 76 | const renderMethod = () => { 77 | tracker.remove(fn) 78 | return '
success
' 79 | } 80 | 81 | const result = await tracker.wait(renderMethod) 82 | 83 | expect(result).toBe('
success
') 84 | expect(tracker.renderCycle).toBe(1) 85 | expect(logger.error.notCalled).toBe(true) 86 | }) 87 | 88 | it('increases render limit when there are unfinished flows after render', async () => { 89 | const logger = { error: sandbox.spy() } 90 | const tracker = newTracker({ logger }) 91 | tracker.add(fn) 92 | const renderMethod = () => { setTimeout(() => tracker.remove(fn), 500) } 93 | 94 | await tracker.wait(renderMethod) 95 | 96 | expect(tracker.renderCycle).toBe(2) 97 | expect(logger.error.notCalled).toBe(true) 98 | }) 99 | 100 | it('stops waiting for render loop when timeLimit is reached', async () => { 101 | const logger = { error: sandbox.spy() } 102 | const tracker = newTracker({ timeLimit: 500, logger }) 103 | tracker.add(fn) 104 | const renderMethod = () => { setTimeout(() => tracker.remove(fn), 1000) } 105 | 106 | await tracker.wait(renderMethod) 107 | 108 | expect(logger.error.called).toBe(true) 109 | }) 110 | 111 | it('stops waiting for render loop when render limit is reached', async () => { 112 | const logger = { warn: sandbox.spy() } 113 | const tracker = newTracker({ renderLimit: 1, logger }) 114 | tracker.add(fn) 115 | const renderMethod = () => { setTimeout(() => tracker.remove(fn), 500) } 116 | 117 | await tracker.wait(renderMethod) 118 | 119 | expect(logger.warn.calledOnce).toBe(true) 120 | }) 121 | }) -------------------------------------------------------------------------------- /__tests__/middleware.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | import { types, addMiddleware, flow } from 'mobx-state-tree' 3 | import createMiddleware from '../src/middleware' 4 | import track from '../src/track' 5 | 6 | const mockAction = (opts) => Object.assign({ id: Date.now(), name: Date.now(), type: 'action' }, opts) 7 | const mockAsyncAction = (opts) => { 8 | const name = Date.now() 9 | return mockAction(Object.assign({ name, context: { $arboris: { [name]: { type: 'async' } } } }, opts)) 10 | } 11 | 12 | const noop = () => {} 13 | const falseFn = () => false 14 | const trueFn = () => true 15 | 16 | let sandbox = sinon.createSandbox() 17 | 18 | describe('middleware.js', () => { 19 | afterEach(() => sandbox.restore()) 20 | 21 | it('creates a function', () => { 22 | const middleware = createMiddleware() 23 | expect(middleware).toBeInstanceOf(Function) 24 | }) 25 | 26 | describe('middleware function', () => { 27 | it('adds id and name to tracker for flow_span action', () => { 28 | const tracker = { add: sandbox.spy(), renderLimitReached: falseFn } 29 | const middleware = createMiddleware(tracker, console) 30 | const action = mockAction({ type: 'flow_spawn' }) 31 | 32 | middleware(action, noop) 33 | expect(tracker.add.calledWith(action.id, action.name)).toBe(true) 34 | }) 35 | 36 | it('removes flow from tracker when it is over', () => { 37 | const tracker = { 38 | has: trueFn, 39 | remove: sandbox.spy(), 40 | renderLimitReached: falseFn 41 | } 42 | const middleware = createMiddleware(tracker, console) 43 | const action = mockAction({ type: 'flow_return' }) 44 | 45 | middleware(action, noop) 46 | expect(tracker.remove.calledWith(action.id)).toBe(true) 47 | }) 48 | 49 | it('removes flow from tracker when it fails', () => { 50 | const tracker = { 51 | has: trueFn, 52 | remove: sandbox.spy(), 53 | renderLimitReached: falseFn 54 | } 55 | const middleware = createMiddleware(tracker, console) 56 | const action = mockAction({ type: 'flow_throw' }) 57 | 58 | middleware(action, noop) 59 | expect(tracker.remove.calledWith(action.id)).toBe(true) 60 | }) 61 | 62 | it('sends error to the logger if flow is missing from tracker', () => { 63 | const tracker = { 64 | has: falseFn, 65 | renderLimitReached: falseFn 66 | } 67 | const logger = { error: sandbox.spy() } 68 | 69 | const middleware = createMiddleware(tracker, logger) 70 | const action = mockAction({ type: 'flow_return' }) 71 | 72 | middleware(action, noop) 73 | expect(logger.error.calledOnce).toBe(true) 74 | }) 75 | 76 | it('calls next function if render limit has not been reached', () => { 77 | const tracker = { 78 | has: trueFn, 79 | add: noop, 80 | renderLimitReached: falseFn 81 | } 82 | const next = sandbox.spy() 83 | 84 | const middleware = createMiddleware(tracker, console) 85 | const action = mockAsyncAction() 86 | 87 | middleware(action, next) 88 | expect(next.calledOnce).toBe(true) 89 | }) 90 | 91 | it('does not call next function if render limit has been reached', () => { 92 | const tracker = { 93 | has: trueFn, 94 | add: noop, 95 | renderLimitReached: trueFn 96 | } 97 | const next = sandbox.spy() 98 | 99 | const middleware = createMiddleware(tracker, console) 100 | const action = mockAsyncAction() 101 | 102 | middleware(action, next) 103 | expect(next.notCalled).toBe(true) 104 | }) 105 | 106 | it('does not block non-flow functions if render limit has been reached', () => { 107 | const tracker = { 108 | has: trueFn, 109 | renderLimitReached: trueFn 110 | } 111 | const next = sandbox.spy() 112 | 113 | const middleware = createMiddleware(tracker, console) 114 | const action = mockAction({ type: 'action' }) 115 | 116 | middleware(action, next) 117 | expect(next.calledOnce).toBe(true) 118 | }) 119 | }) 120 | 121 | it('within MST instance', async () => { 122 | const delay = sandbox.stub().resolves(true) 123 | const Store = track( 124 | types.model('Store') 125 | .actions(() => ({ 126 | exampleFlow: flow(function* () { 127 | yield delay() 128 | return 2 129 | }) 130 | })), 131 | { exampleFlow: track.async() } 132 | ) 133 | 134 | const store = Store.create() 135 | 136 | const tracker = { 137 | add: sandbox.spy(), 138 | has: trueFn, 139 | remove: sandbox.spy(), 140 | renderLimitReached: falseFn 141 | } 142 | 143 | const middleware = createMiddleware(tracker, console) 144 | 145 | addMiddleware(store, middleware) 146 | 147 | const foo = await store.exampleFlow() 148 | expect(foo).toBe(2) 149 | }) 150 | }) --------------------------------------------------------------------------------