├── .babelrc ├── .circleci └── config.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .releaserc ├── LICENSE ├── README.md ├── package.json ├── renovate.json ├── rollup.config.js ├── src ├── EffectManager.test.ts ├── EffectManager.ts ├── constants.ts ├── helpers │ ├── getEffectDescription.test.ts │ ├── getEffectDescription.ts │ ├── getEffectName.test.ts │ ├── getEffectName.ts │ ├── isRaceEffect.test.ts │ └── isRaceEffect.ts ├── index.ts └── sagaMonitor.ts ├── tsconfig.json ├── wallaby.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | 6 | defaults: &defaults 7 | docker: 8 | - image: circleci/node:10.11 9 | working_directory: ~/repo 10 | 11 | version: 2 12 | jobs: 13 | setup: 14 | <<: *defaults 15 | steps: 16 | - checkout 17 | - restore_cache: 18 | name: Restore node modules 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v1-dependencies- 23 | - run: 24 | name: Install dependencies 25 | command: yarn install 26 | - save_cache: 27 | name: Save node modules 28 | paths: 29 | - node_modules 30 | key: v1-dependencies-{{ checksum "package.json" }} 31 | 32 | tests: 33 | <<: *defaults 34 | steps: 35 | - checkout 36 | - restore_cache: 37 | name: Restore node modules 38 | keys: 39 | - v1-dependencies-{{ checksum "package.json" }} 40 | # fallback to using the latest cache if no exact match is found 41 | - v1-dependencies- 42 | - run: 43 | name: Run tests 44 | command: yarn ci:test 45 | 46 | build: 47 | <<: *defaults 48 | steps: 49 | - checkout 50 | - restore_cache: 51 | name: Restore node modules 52 | keys: 53 | - v1-dependencies-{{ checksum "package.json" }} 54 | # fallback to using the latest cache if no exact match is found 55 | - v1-dependencies- 56 | - run: 57 | name: Run Build 58 | command: yarn build 59 | - save_cache: 60 | name: Save node modules 61 | paths: 62 | - dist 63 | key: v1-build-{{ .Branch }}-{{ .Revision }} 64 | 65 | publish: 66 | <<: *defaults 67 | steps: 68 | - checkout 69 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 70 | - restore_cache: 71 | name: Restore node modules 72 | keys: 73 | - v1-dependencies-{{ checksum "package.json" }} 74 | # fallback to using the latest cache if no exact match is found 75 | - v1-dependencies- 76 | - restore_cache: 77 | name: Restore build 78 | keys: 79 | - v1-build-{{ .Branch }}-{{ .Revision }} 80 | - run: 81 | name: Publish to NPM 82 | command: yarn ci:publish 83 | 84 | workflows: 85 | version: 2 86 | test_and_release: 87 | jobs: 88 | - setup 89 | - tests: 90 | requires: 91 | - setup 92 | - build: 93 | requires: 94 | - tests 95 | - publish: 96 | requires: 97 | - build 98 | filters: 99 | branches: 100 | only: 101 | - master 102 | - next 103 | - beta 104 | - alpha 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .nyc_output 5 | dist 6 | yarn-error.log 7 | .idea 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.vscode 2 | **/android 3 | **/build 4 | **/compiled 5 | **/dist 6 | **/ios 7 | **/package.json 8 | **/release 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/npm", 6 | "@semantic-release/github", 7 | [ 8 | "@semantic-release/git", 9 | { 10 | "assets": "package.json", 11 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 - 3016 Infinite Red LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > This plugin has been deprecated since `redux-saga` has become less popular compared to other solutions. If you would like to see it revived, please file an issue at https://github.com/infinitered/reactotron/issues or consider submitting a PR at https://github.com/infinitered/reactotron/. 3 | 4 | # reactotron-redux-saga 5 | 6 | A `reactotron` plugin to provide insights into `redux-saga` 7 | 8 | ## Version compatibility 9 | 10 | `redux-saga` version 1.0.0 support was introduced in `reactotron-redux-saga` version 3.0.0. If you are using a version of `redux-saga` older then 1.0.0 please use `reactotron-redux-saga` version 2.1.4. 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactotron-redux-saga", 3 | "version": "4.2.3", 4 | "description": "A Reactotron plugin for Redux Saga.", 5 | "author": "Infinite Red", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/infinitered/reactotron-redux-saga/issues" 9 | }, 10 | "homepage": "https://github.com/infinitered/reactotron-redux-saga", 11 | "repository": "https://github.com/infinitered/reactotron-redux-saga", 12 | "files": [ 13 | "dist", 14 | "LICENSE", 15 | "README.md", 16 | "reactotron-redux-saga.d.ts" 17 | ], 18 | "main": "dist/index.js", 19 | "types": "./dist/types/index.d.ts", 20 | "scripts": { 21 | "test": "jest", 22 | "test:watch": "jest --watch --notify", 23 | "format": "prettier --write {**,.}/*.ts", 24 | "build": "npm-run-all clean tsc compile", 25 | "build:dev": "npm-run-all clean tsc compile:dev", 26 | "clean": "trash dist", 27 | "lint": "eslint src --ext .ts,.tsx", 28 | "compile": "NODE_ENV=production rollup -c", 29 | "compile:dev": "NODE_ENV=development rollup -c", 30 | "tsc": "tsc", 31 | "ci:test": "yarn test", 32 | "ci:publish": "yarn semantic-release", 33 | "semantic-release": "semantic-release" 34 | }, 35 | "peerDependencies": { 36 | "@redux-saga/is": "^1.0.1", 37 | "reactotron-core-client": "^2.5.0", 38 | "redux": "^4.0.1", 39 | "redux-saga": "^1.0.1" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.2.2", 43 | "@babel/preset-env": "^7.3.1", 44 | "@babel/preset-typescript": "^7.1.0", 45 | "@redux-saga/is": "^1.0.1", 46 | "@redux-saga/testing-utils": "^1.0.1", 47 | "@semantic-release/git": "^7.1.0-beta.3", 48 | "@types/jest": "^24.0.0", 49 | "@typescript-eslint/eslint-plugin": "^1.3.0", 50 | "@typescript-eslint/parser": "^1.3.0", 51 | "babel-eslint": "^10.0.1", 52 | "babel-jest": "^24.1.0", 53 | "eslint": "^5.13.0", 54 | "eslint-config-prettier": "^4.0.0", 55 | "eslint-config-standard": "^12.0.0", 56 | "eslint-plugin-import": "^2.16.0", 57 | "eslint-plugin-node": "^8.0.1", 58 | "eslint-plugin-promise": "^4.0.1", 59 | "eslint-plugin-standard": "^4.0.0", 60 | "jest": "^24.1.0", 61 | "npm-run-all": "^4.1.5", 62 | "prettier": "^1.16.4", 63 | "reactotron-core-client": "^2.5.0", 64 | "redux": "^4.0.1", 65 | "redux-saga": "^1.0.1", 66 | "rollup": "^1.1.2", 67 | "rollup-plugin-babel": "^4.3.2", 68 | "rollup-plugin-babel-minify": "^7.0.0", 69 | "rollup-plugin-filesize": "^6.0.1", 70 | "rollup-plugin-node-resolve": "^4.0.0", 71 | "rollup-plugin-resolve": "^0.0.1-predev.1", 72 | "semantic-release": "^16.0.0-beta.36", 73 | "trash-cli": "^1.4.0", 74 | "ts-jest": "^23.10.5", 75 | "typescript": "^3.3.3" 76 | }, 77 | "eslintConfig": { 78 | "parser": "@typescript-eslint/parser", 79 | "extends": [ 80 | "plugin:@typescript-eslint/recommended", 81 | "standard", 82 | "prettier" 83 | ], 84 | "parserOptions": { 85 | "ecmaFeatures": { 86 | "jsx": true 87 | }, 88 | "project": "./tsconfig.json" 89 | }, 90 | "plugins": [ 91 | "@typescript-eslint" 92 | ], 93 | "globals": { 94 | "__DEV__": false, 95 | "jasmine": false, 96 | "beforeAll": false, 97 | "afterAll": false, 98 | "beforeEach": false, 99 | "afterEach": false, 100 | "test": false, 101 | "expect": false, 102 | "describe": false, 103 | "jest": false, 104 | "it": false 105 | }, 106 | "rules": { 107 | "no-unused-vars": 0, 108 | "no-undef": 0, 109 | "space-before-function-paren": 0, 110 | "@typescript-eslint/indent": 0, 111 | "@typescript-eslint/explicit-member-accessibility": 0, 112 | "@typescript-eslint/explicit-function-return-type": 0, 113 | "@typescript-eslint/no-explicit-any": 0, 114 | "@typescript-eslint/no-object-literal-type-assertion": 0, 115 | "@typescript-eslint/no-empty-interface": 0, 116 | "@typescript-eslint/no-var-requires": 0, 117 | "@typescript-eslint/member-delimiter-style": 0 118 | } 119 | }, 120 | "jest": { 121 | "preset": "ts-jest", 122 | "testEnvironment": "node", 123 | "testMatch": [ 124 | "**/*.test.[tj]s" 125 | ] 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve" 2 | import babel from "rollup-plugin-babel" 3 | import filesize from "rollup-plugin-filesize" 4 | import minify from "rollup-plugin-babel-minify" 5 | 6 | export default { 7 | input: "src/index.ts", 8 | output: { 9 | file: "dist/index.js", 10 | format: "cjs", 11 | }, 12 | plugins: [ 13 | resolve({ extensions: [".ts"] }), 14 | babel({ extensions: [".ts"], runtimeHelpers: true }), 15 | process.env.NODE_ENV === "production" 16 | ? minify({ 17 | comments: false, 18 | }) 19 | : null, 20 | filesize(), 21 | ], 22 | external: ["@redux-saga/is", "redux-saga/effects", "@babel/runtime/helpers/esm/extends", "reactotron-core-client"], 23 | } 24 | -------------------------------------------------------------------------------- /src/EffectManager.test.ts: -------------------------------------------------------------------------------- 1 | import EffectManager from "./EffectManager" 2 | 3 | describe("EffectManager", () => { 4 | it("should track rootIds", () => { 5 | const manager = new EffectManager() 6 | 7 | manager.setRootEffect(0, { effectId: 0, status: "a test" }) 8 | 9 | expect(manager.getRootIds()).toEqual([0]) 10 | }) 11 | 12 | it("should track effects and return them", () => { 13 | const manager = new EffectManager() 14 | 15 | manager.set(1, { effectId: 1, status: "a test", parentEffectId: 0 }) 16 | 17 | expect(manager.get(1)).toEqual({ effectId: 1, status: "a test", parentEffectId: 0 }) 18 | }) 19 | 20 | it("should return children ids", () => { 21 | const manager = new EffectManager() 22 | 23 | manager.set(1, { effectId: 1, status: "a test", parentEffectId: 0 }) 24 | manager.set(2, { effectId: 2, status: "another test", parentEffectId: 1 }) 25 | manager.set(3, { effectId: 3, status: "another test", parentEffectId: 1 }) 26 | manager.set(4, { effectId: 4, status: "another test", parentEffectId: 2 }) 27 | 28 | expect(manager.getChildIds(1)).toEqual([2, 3]) 29 | expect(manager.getChildIds(2)).toEqual([4]) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/EffectManager.ts: -------------------------------------------------------------------------------- 1 | import { Saga } from "redux-saga" 2 | import { Effect } from "@redux-saga/types" 3 | 4 | export interface MonitoredEffect { 5 | effectId: number 6 | parentEffectId?: number 7 | name?: string 8 | description?: string 9 | saga?: Saga 10 | root?: boolean 11 | args?: any[] 12 | status: string 13 | start?: number 14 | end?: number 15 | duration?: number 16 | error?: any 17 | label?: string 18 | winner?: boolean 19 | result?: any 20 | 21 | effect?: Effect 22 | } 23 | 24 | export default class EffectManager { 25 | rootIds: number[] 26 | map: { [id: number]: MonitoredEffect } 27 | childIdsMap: { [id: number]: number[] } 28 | 29 | constructor() { 30 | this.rootIds = []; 31 | this.map = {} 32 | this.childIdsMap = {} 33 | } 34 | 35 | get(effectId: number) { 36 | return this.map[effectId] 37 | } 38 | 39 | set(effectId: number, desc: MonitoredEffect) { 40 | this.map[effectId] = desc 41 | 42 | if (!this.childIdsMap[desc.parentEffectId]) { 43 | this.childIdsMap[desc.parentEffectId] = [] 44 | } 45 | this.childIdsMap[desc.parentEffectId].push(effectId) 46 | } 47 | 48 | setRootEffect(effectId: number, desc: MonitoredEffect) { 49 | this.rootIds.push(effectId) 50 | this.set(effectId, { ...desc, root: true }) 51 | } 52 | 53 | getRootIds() { 54 | return this.rootIds 55 | } 56 | 57 | getChildIds(parentEffectId: number) { 58 | return this.childIdsMap[parentEffectId] || [] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { effectTypes } from "redux-saga/effects" 2 | 3 | const { 4 | TAKE, 5 | PUT, 6 | ALL, 7 | RACE, 8 | CALL, 9 | CPS, 10 | FORK, 11 | JOIN, 12 | CANCEL, 13 | SELECT, 14 | ACTION_CHANNEL, 15 | CANCELLED, 16 | FLUSH, 17 | GET_CONTEXT, 18 | SET_CONTEXT, 19 | } = effectTypes 20 | const PARALLEL = "PARALLEL" 21 | const ITERATOR = "ITERATOR" 22 | const PROMISE = "PROMISE" // not from redux-saga 23 | const UNKNOWN = "UNKNOWN" // not from redux-saga 24 | 25 | // monitoring statuses 26 | const PENDING = "PENDING" 27 | const RESOLVED = "RESOLVED" 28 | const REJECTED = "REJECTED" 29 | 30 | export { 31 | TAKE, 32 | PUT, 33 | ALL, 34 | RACE, 35 | CALL, 36 | CPS, 37 | FORK, 38 | JOIN, 39 | CANCEL, 40 | SELECT, 41 | ACTION_CHANNEL, 42 | CANCELLED, 43 | FLUSH, 44 | GET_CONTEXT, 45 | SET_CONTEXT, 46 | PARALLEL, 47 | ITERATOR, 48 | PROMISE, 49 | UNKNOWN, 50 | PENDING, 51 | RESOLVED, 52 | REJECTED, 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/getEffectDescription.test.ts: -------------------------------------------------------------------------------- 1 | import * as effectTypes from "../constants" 2 | 3 | import getEffectDescription from "./getEffectDescription" 4 | 5 | describe("getEffectDescription", () => { 6 | it("should return unknown", () => { 7 | expect(getEffectDescription(null)).toBe(effectTypes.UNKNOWN) 8 | }) 9 | 10 | it("should return the saga name if it is the root saga", () => { 11 | // TODO: Investigate this. 12 | // expect(getEffectDescription({ root: true, saga: { name: 'test' }})).toBe("test") 13 | }) 14 | 15 | it("should handle a promise with a name", () => { 16 | const promise = new Promise(resolve => resolve()) 17 | ;(promise as any).name = "Testing" 18 | expect(getEffectDescription(promise)).toBe(`${effectTypes.PROMISE}(Testing)`) 19 | }) 20 | 21 | it("should handle a promise with an anonymous function", () => { 22 | expect(getEffectDescription(new Promise(resolve => resolve()))).toBe(effectTypes.PROMISE) 23 | }) 24 | 25 | it("should hand a class extending a promise", () => { 26 | // TODO: Investigate this 27 | // class ExtPromise extends Promise {} 28 | // expect(getEffectDescription(new ExtPromise(resolve => resolve()))).toBe("ExtPromise") 29 | }) 30 | 31 | it("should handle an iterator", () => { 32 | // TODO: Investigate this. 33 | // function* aGen() { 34 | // yield Effects.put({ type: "LOL" }) 35 | // } 36 | // expect(getEffectDescription(aGen())).toBe("aGen") 37 | }) 38 | 39 | // TODO: Write tests for everything else eventually. 40 | }) 41 | -------------------------------------------------------------------------------- /src/helpers/getEffectDescription.ts: -------------------------------------------------------------------------------- 1 | import * as is from "@redux-saga/is" 2 | import { Effect } from "@redux-saga/types" 3 | 4 | import * as effectTypes from "../constants" 5 | 6 | function getEffectDescription(effect: Effect | any[] | IterableIterator | Promise) { 7 | if (!effect) return effectTypes.UNKNOWN 8 | 9 | if ((effect as any).root) return (effect as any).saga.name // TODO: Better typing 10 | if (is.iterator(effect)) { 11 | return (effect as any).name || effectTypes.UNKNOWN 12 | } 13 | if (is.array(effect)) return null 14 | if (is.promise(effect)) { 15 | let display: string 16 | if ((effect as any).name) { 17 | // a promise object with a manually set name prop for display reasons 18 | display = `${effectTypes.PROMISE}(${(effect as any).name})` 19 | } else if (effect.constructor instanceof Promise.constructor) { 20 | // an anonymous promise 21 | display = effectTypes.PROMISE 22 | } else { 23 | // class which extends Promise, so output the name of the class to precise 24 | display = `${effectTypes.PROMISE}(${effect.constructor.name})` 25 | } 26 | return display 27 | } 28 | if (is.effect(effect)) { 29 | const { type, payload: data } = effect as Effect 30 | if (type === effectTypes.TAKE) { 31 | return data.pattern || "channel" 32 | } else if (type === effectTypes.PUT) { 33 | return data.channel ? data.action : data.action.type 34 | } else if (type === effectTypes.ALL) { 35 | return null 36 | } else if (type === effectTypes.RACE) { 37 | return null 38 | } else if (type === effectTypes.CALL) { 39 | return !data.fn.name || data.fn.name.trim() === "" ? "(anonymous)" : data.fn.name 40 | } else if (type === effectTypes.CPS) { 41 | return data.fn.name 42 | } else if (type === effectTypes.FORK) { 43 | return data.fn.name 44 | } else if (type === effectTypes.JOIN) { 45 | return data.name 46 | } else if (type === effectTypes.CANCEL) { 47 | return data.name 48 | } else if (type === effectTypes.SELECT) { 49 | return data.selector.name 50 | } else if (type === effectTypes.ACTION_CHANNEL) { 51 | return data.buffer == null ? data.pattern : data 52 | } else if (type === effectTypes.CANCELLED) { 53 | return null 54 | } else if (type === effectTypes.FLUSH) { 55 | return data 56 | } else if (type === effectTypes.GET_CONTEXT) { 57 | return data 58 | } else if (type === effectTypes.SET_CONTEXT) { 59 | return data 60 | } 61 | } 62 | 63 | return effectTypes.UNKNOWN 64 | } 65 | 66 | export default getEffectDescription 67 | -------------------------------------------------------------------------------- /src/helpers/getEffectName.test.ts: -------------------------------------------------------------------------------- 1 | import * as Effects from "redux-saga/effects" 2 | import { createMockTask } from '@redux-saga/testing-utils' 3 | 4 | import * as effectTypes from "../constants" 5 | 6 | import getEffectName from "./getEffectName" 7 | 8 | describe("getEffectName", () => { 9 | it("should return null if no effect sent", () => { 10 | expect(getEffectName(null)).toBe(effectTypes.UNKNOWN) 11 | }) 12 | 13 | it("should return an parallel type", () => { 14 | expect(getEffectName([])).toBe(effectTypes.PARALLEL) 15 | }) 16 | 17 | it("should return a iterator type", () => { 18 | function* testGen() {} 19 | 20 | expect(getEffectName(testGen())).toBe(effectTypes.ITERATOR) 21 | }) 22 | 23 | it("should return a promise type", () => { 24 | expect(getEffectName(new Promise(resolve => resolve()))).toBe(effectTypes.PROMISE) 25 | }) 26 | 27 | it("should return built in effect types", () => { 28 | function* testGen() {} 29 | 30 | expect(getEffectName(Effects.take())).toBe(effectTypes.TAKE) 31 | expect(getEffectName(Effects.put({ type: "TEST" }))).toBe(effectTypes.PUT) 32 | expect(getEffectName(Effects.all({}))).toBe(effectTypes.ALL) 33 | expect(getEffectName(Effects.call(() => {}))).toBe(effectTypes.CALL) 34 | expect(getEffectName(Effects.cps(() => {}))).toBe(effectTypes.CPS) 35 | expect(getEffectName(Effects.fork(testGen))).toBe(effectTypes.FORK) 36 | expect(getEffectName(Effects.actionChannel("TEST"))).toBe(effectTypes.ACTION_CHANNEL) 37 | expect(getEffectName(Effects.cancelled())).toBe(effectTypes.CANCELLED) 38 | expect(getEffectName(Effects.getContext("A string"))).toBe(effectTypes.GET_CONTEXT) 39 | expect(getEffectName(Effects.setContext({}))).toBe(effectTypes.SET_CONTEXT) 40 | expect(getEffectName(Effects.join(createMockTask()))).toBe(effectTypes.JOIN) 41 | expect(getEffectName(Effects.race([createMockTask(), createMockTask()]))).toBe(effectTypes.RACE) 42 | expect(getEffectName(Effects.cancel(createMockTask()))).toBe(effectTypes.CANCEL) 43 | expect(getEffectName(Effects.select(() => {}))).toBe(effectTypes.SELECT) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/helpers/getEffectName.ts: -------------------------------------------------------------------------------- 1 | import * as is from "@redux-saga/is" 2 | import { Effect } from "@redux-saga/types" 3 | 4 | import * as effectTypes from "../constants" 5 | 6 | function getEffectName(effect: Effect | any[] | IterableIterator | Promise) { 7 | if (is.array(effect)) return effectTypes.PARALLEL 8 | if (is.iterator(effect)) return effectTypes.ITERATOR 9 | if (is.promise(effect)) return effectTypes.PROMISE 10 | 11 | if (is.effect(effect)) { 12 | return effect.type 13 | } 14 | 15 | return effectTypes.UNKNOWN 16 | } 17 | 18 | export default getEffectName 19 | -------------------------------------------------------------------------------- /src/helpers/isRaceEffect.test.ts: -------------------------------------------------------------------------------- 1 | import * as Effects from "redux-saga/effects" 2 | 3 | import { isRaceEffect } from "./isRaceEffect" 4 | import { createMockTask } from "@redux-saga/testing-utils" 5 | 6 | describe("isRaceEffect", () => { 7 | it("should detect a race effect", () => { 8 | expect(isRaceEffect(Effects.race([createMockTask()]))).toBeTruthy() 9 | }) 10 | 11 | it("should tell us something isn't a race effect", () => { 12 | expect(isRaceEffect(Effects.take())).toBeFalsy() 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/helpers/isRaceEffect.ts: -------------------------------------------------------------------------------- 1 | import * as is from "@redux-saga/is" 2 | import { Effect } from "@redux-saga/types"; 3 | 4 | import * as effectTypes from "../constants" 5 | 6 | export const isRaceEffect = (eff: Effect) => is.effect(eff) && eff.type === effectTypes.RACE 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Reactotron } from "reactotron-core-client" 2 | import { SagaMonitor } from "redux-saga"; 3 | 4 | import createSagaMonitor, { PluginConfig } from "./sagaMonitor" 5 | 6 | export default (pluginConfig: PluginConfig) => (reactotron: Reactotron) => ({ 7 | features: { 8 | createSagaMonitor: () => createSagaMonitor(reactotron, pluginConfig), 9 | }, 10 | }) 11 | 12 | declare module "reactotron-core-client" { 13 | // eslint-disable-next-line import/export 14 | export interface Reactotron { 15 | createSagaMonitor?: () => SagaMonitor 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/sagaMonitor.ts: -------------------------------------------------------------------------------- 1 | import { SagaMonitor, Saga } from "@redux-saga/core" 2 | import * as is from "@redux-saga/is" 3 | import { Effect } from "@redux-saga/types" 4 | import { Reactotron } from "reactotron-core-client" 5 | 6 | import * as effectTypes from "./constants" 7 | import EffectManager, { MonitoredEffect } from "./EffectManager" 8 | import getEffectName from "./helpers/getEffectName" 9 | import getEffectDescription from "./helpers/getEffectDescription" 10 | import { isRaceEffect } from "./helpers/isRaceEffect" 11 | 12 | export interface PluginConfig { 13 | except?: string[] 14 | } 15 | 16 | export default (reactotron: Reactotron, pluginConfig: PluginConfig = {}): SagaMonitor => { 17 | const manager = new EffectManager() 18 | const exceptions = pluginConfig.except || [] 19 | const timer = reactotron.startTimer() 20 | 21 | function computeEffectDuration(effect: MonitoredEffect) { 22 | const now = timer() 23 | 24 | effect.end = now 25 | effect.duration = now - effect.start 26 | } 27 | 28 | // Scale children building up metadata for sending to the other side. 29 | function buildChildEffects(depth: number, effectId: number, children: any[]) { 30 | const effect = manager.get(effectId) 31 | if (!effect) return 32 | 33 | let extra = null 34 | 35 | if (effect.name) { 36 | switch (effect.name) { 37 | case effectTypes.CALL: 38 | extra = effect.effect.payload.args 39 | break 40 | case effectTypes.PUT: 41 | extra = effect.effect.payload.action 42 | break 43 | case effectTypes.RACE: 44 | // Do Nothing for now 45 | break 46 | default: 47 | extra = (effect.effect || {} as Effect).payload 48 | break 49 | } 50 | } 51 | 52 | children.push({ 53 | depth, 54 | effectId: effect.effectId, 55 | parentEffectId: effect.parentEffectId || null, 56 | name: effect.name || null, 57 | description: effect.description || null, 58 | duration: Math.round(effect.duration), 59 | status: effect.status || null, 60 | winner: effect.winner || null, 61 | result: effect.result || null, 62 | extra: extra || null, 63 | }) 64 | 65 | manager 66 | .getChildIds(effectId) 67 | .forEach(childEffectId => buildChildEffects(depth + 1, childEffectId, children)) 68 | } 69 | 70 | // This is the method called when the below events think we are ready to ship a saga to reactotron. 71 | function shipEffect(effectId: number) { 72 | const effect = manager.get(effectId) 73 | computeEffectDuration(effect) 74 | 75 | // If we are on the exception list bail fast. 76 | if (exceptions.indexOf(effect.description) > -1) return 77 | 78 | // a human friendly name of the saga task 79 | let sagaDescription 80 | // what caused the trigger 81 | let triggerType 82 | const children = [] 83 | 84 | const parentEffect = manager.get(effect.parentEffectId) 85 | 86 | // If we are a fork effect then we need to collect up everything that happened in us to ship that 87 | if (effect.name && effect.name === effectTypes.FORK) { 88 | const { args } = effect.effect.payload 89 | const lastArg = args.length > 0 ? args[args.length - 1] : null 90 | triggerType = lastArg && lastArg.type 91 | 92 | if (parentEffect) { 93 | if (parentEffect.name && parentEffect.name === effectTypes.ITERATOR) { 94 | sagaDescription = parentEffect.description 95 | } 96 | } else { 97 | sagaDescription = "(root)" 98 | triggerType = `${effect.description}()` 99 | } 100 | 101 | manager 102 | .getChildIds(effectId) 103 | .forEach(childEffectId => buildChildEffects(0, childEffectId, children)) 104 | } 105 | 106 | reactotron.send("saga.task.complete", { 107 | triggerType: triggerType || effect.description, 108 | description: sagaDescription, 109 | duration: Math.round(effect.duration), 110 | children, 111 | }) 112 | } 113 | 114 | function setRaceWinner(raceEffectId: number, result: any) { 115 | const winnerLabel = Object.keys(result)[0] 116 | 117 | manager.getChildIds(raceEffectId).forEach(childId => { 118 | const childEffect = manager.get(childId) 119 | if (childEffect.label === winnerLabel) { 120 | childEffect.winner = true 121 | } 122 | }) 123 | } 124 | 125 | function rootSagaStarted(options: { effectId: number; saga: Saga; args: any[] }) { 126 | manager.setRootEffect(options.effectId, { 127 | ...options, 128 | status: effectTypes.PENDING, 129 | start: timer(), 130 | }) 131 | } 132 | 133 | function effectTriggered(options: { 134 | effectId: number 135 | parentEffectId: number 136 | label?: string 137 | effect: any 138 | }) { 139 | manager.set(options.effectId, { 140 | ...options, 141 | status: effectTypes.PENDING, 142 | start: timer(), 143 | name: getEffectName(options.effect), 144 | description: getEffectDescription(options.effect), 145 | }) 146 | } 147 | 148 | function effectRejected(effectId: number, error: any) { 149 | const effect = manager.get(effectId) 150 | 151 | computeEffectDuration(effect) 152 | effect.status = effectTypes.REJECTED 153 | effect.error = error 154 | 155 | if (isRaceEffect(effect.effect)) { 156 | setRaceWinner(effectId, error) 157 | } 158 | } 159 | 160 | function effectCancelled(effectId: number) { 161 | const effect = manager.get(effectId) 162 | 163 | computeEffectDuration(effect) 164 | effect.status = effectTypes.CANCELLED 165 | } 166 | 167 | function effectResolved(effectId: number, result: any) { 168 | const effect = manager.get(effectId) 169 | 170 | if (is.task(result)) { 171 | result.toPromise().then( 172 | taskResult => { 173 | if (result.isCancelled()) { 174 | effectCancelled(effectId) 175 | } else { 176 | effectResolved(effectId, taskResult) 177 | shipEffect(effectId) 178 | } 179 | }, 180 | taskError => { 181 | effectRejected(effectId, taskError) 182 | 183 | if (!taskError.reactotronWasHere) { 184 | reactotron.reportError(taskError) 185 | } 186 | taskError.reactotronWasHere = true 187 | } 188 | ) 189 | } else { 190 | computeEffectDuration(effect) 191 | effect.status = effectTypes.RESOLVED 192 | effect.result = result 193 | 194 | if (isRaceEffect(effect.effect)) { 195 | setRaceWinner(effectId, result) 196 | } 197 | } 198 | } 199 | 200 | return { 201 | rootSagaStarted, 202 | effectTriggered, 203 | effectResolved, 204 | effectRejected, 205 | effectCancelled, 206 | actionDispatched: () => {}, 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "declarationDir": "dist/types", 6 | "emitDeclarationOnly": true, 7 | "emitDecoratorMetadata": true, 8 | "allowSyntheticDefaultImports": true, 9 | "experimentalDecorators": true, 10 | "module": "es2015", 11 | "moduleResolution": "node", 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "sourceMap": true, 17 | "target": "es2015" 18 | }, 19 | "exclude": ["node_modules"], 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function(wallaby) { 2 | return { 3 | files: ["src/**/*.ts", "!src/**/*.test.ts"], 4 | 5 | tests: ["src/**/*.test.ts"], 6 | 7 | compilers: { 8 | "**/*.ts": wallaby.compilers.babel(), 9 | }, 10 | 11 | env: { 12 | type: "node", 13 | runner: "node", 14 | }, 15 | 16 | testFramework: "jest", 17 | } 18 | } 19 | --------------------------------------------------------------------------------