├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── cicd-workflow ├── README.md └── github-action-generator.js ├── dockerfile-generator ├── README.md └── dockerfile-generator.js ├── jest.config.js ├── lib ├── bundle.main.js └── bundle.puppeteer.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __test__ │ └── index.test.js ├── dockerfile-generator │ └── index.js ├── index.ts ├── schemas │ └── node-schema.ts └── utils │ ├── Observable.ts │ ├── Tree.ts │ ├── TreeNode.ts │ └── utils.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "10" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | "ignore": ["**/__tests__", "\\.test\\.js", "**/dockerfile-generator", "**/cicd-workflow"] 15 | } 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/** -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2020, 4 | "sourceType": "module", 5 | "rules": { 6 | "strict": ["error", "never"] 7 | } 8 | }, 9 | "ignorePatterns": "./lib/**", 10 | "extends": ["eslint:recommended", "eslint-config-prettier"], 11 | "rules": { 12 | "strict": ["error", "never"] 13 | }, 14 | "env": { 15 | "browser": true, 16 | "jest/globals": true, 17 | "node": true 18 | }, 19 | "overrides": [ 20 | { 21 | "files": "**/*.+(ts|tsx)", 22 | "parser": "@typescript-eslint/parser", 23 | "parserOptions": { 24 | "project": "./tsconfig.json" 25 | }, 26 | "plugins": ["@typescript-eslint/eslint-plugin", "jest"], 27 | "extends": [ 28 | "plugin:@typescript-eslint/eslint-recommended", 29 | "plugin:@typescript-eslint/recommended", 30 | "eslint-config-prettier/@typescript-eslint" 31 | ], 32 | "rules": { 33 | "@typescript-eslint/ban-ts-comment": "off" 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish NPM Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | 35 | publish-gpr: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-node@v1 41 | with: 42 | node-version: 12 43 | registry-url: https://npm.pkg.github.com/ 44 | - run: npm ci 45 | - run: npm publish 46 | env: 47 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .DS_Store 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | # Rollup output 120 | .lib/ 121 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm run check-types && lint-staged && npm run build" 4 | } 5 | } -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.+(js|ts|tsx)": [ 3 | "eslint" 4 | ], 5 | "**/*.+(js|json|ts)": [ 6 | "prettier --write", 7 | "git add" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 140, 9 | "proseWrap": "always", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "trailingComma": "all", 16 | "useTabs": false 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 | # React Pinpoint 7 | 8 | An open source utility library for measuring React component render times. 9 | 10 | [![Build Status](https://travis-ci.org/joemccann/dillinger.svg?branch=master)](https://travis-ci.org/joemccann/dillinger) 11 | 12 | ## Table of Contents 13 | 14 | - [Prerequisites](#prerequisites) 15 | - [Browser context](#browser-context) 16 | - [Production build with a twist](#production-build-with-a-twist) 17 | - [APIs](#apis) 18 | - [record](#record--) 19 | - [report](#report--) 20 | - [Examples](#examples) 21 | - [Using with Puppeteer](#using-with-puppeteer) 22 | - [Getting Started](#getting-started) 23 | - [Installation](#installation) 24 | - [Docker](#docker) 25 | - [FAQS](#faqs) 26 | 27 | ## Prerequisites 28 | 29 | ### Browser context 30 | 31 | React pinpoint must run inside a browser context to observe the React fiber tree. We recommended using automation software such as 32 | [puppeteer](https://github.com/puppeteer/puppeteer) to achieve this. 33 | 34 | ### Production build with a twist 35 | 36 | React optimises your development code base when you build for production, which decreases component render times. Users should therefore run 37 | react pinpoint against their production code 38 | 39 | However, tweaks need to be made to the build process to preserve component names and enable render time profiling. Steps for doing so can be 40 | [found here](https://gist.github.com/bvaughn/25e6233aeb1b4f0cdb8d8366e54a3977). 41 | 42 | ## APIs 43 | 44 | ### `record(page, url, rootId)` 45 | 46 | - `page` <[Page](https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-page)> Puppeteeer page instance 47 | - `url` <[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type)> address React page is hosted at 48 | - `rootId` <[string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type)> id of dom element that React is 49 | mounted to 50 | - returns: <[Page](https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-page)> Puppeteeer page instance with a listener 51 | attached to React components 52 | 53 | This function attaches a listener to the Puppeteer page's react root for recording changes 54 | 55 | ### `report(page, threshold)` 56 | 57 | - `page` <[Page](https://github.com/puppeteer/puppeteer/blob/v5.2.1/docs/api.md#class-page)> Puppeteeer page instance with record listener 58 | attached 59 | - `threshold` <[Number](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type)> cutoff for acceptable 60 | component render time (in ms) 61 | - default time is 16ms 62 | - returns: [Node[]](https://developer.mozilla.org/en-US/docs/Glossary/array) An array of nodes belonging to each react component that 63 | exceeded the given render time threshold 64 | 65 | Will report all component render times that exceed the given threshold in ms 66 | 67 | If no threshold is given, the default threshold of 16ms is used (please see [FAQ “Why is the default render threshold 16ms?”](<(#faqs)>)) 68 | 69 | ## Examples 70 | 71 | ### Using with Puppeteer 72 | 73 | ```javascript 74 | const puppeteer = require('puppeteer'); 75 | const reactPinpoint = require('react-pinpoint'); 76 | 77 | (async () => { 78 | const browser = await puppeteer.launch({}); 79 | const page = await browser.newPage(); 80 | 81 | // Pass information to 82 | const url = 'http://localhost:3000/calculator'; 83 | const rootId = '#root'; 84 | await reactPinpoint.record(page, url, rootId); 85 | 86 | // Perform browser actions 87 | await page.click('#yeah1'); 88 | await page.click('#yeah2'); 89 | await page.click('#yeah3'); 90 | 91 | // Get all components that took longer than 16ms to render during browser actions 92 | const threshold = 16; 93 | const slowRenders = await reactPinpoint.reportTestResults(page, threshold); 94 | 95 | await browser.close(); 96 | })(); 97 | ``` 98 | 99 | ## Getting Started 100 | 1. Head over to the React Pinpoint [website](https://reactpinpoint.com). 101 | 2. Register for an account. 102 | 3. Add a project and fill in the details. 103 | 4. Copy the project ID provided. 104 | 105 | ## Installation 106 | Using npm: 107 | ```shell 108 | npm install -D react-pinpoint 109 | ``` 110 | Using yarn: 111 | ```shell 112 | yarn add react-pinpoint -D 113 | ``` 114 | - Invoke `mountToReactRoot` and paste the project ID you received from the website as the second argument in your React project’s entry file: 115 | ```javascript 116 | mountToReactRoot(rootDom, projectID); 117 | ``` 118 | - Interact with your app and data will be sent to React Pinpoint website. 119 | - Refresh the page and see your data displayed! 120 | 121 | ## Docker 122 | 123 | React pinpoint was designed with the goal of regression testing component render times within a CICD, we therefore offer several 124 | preconfigured docker containers to assist with using React pinpoint within a CICD as well as with examples for doing so. 125 | 126 | - [Puppeteer](https://github.com/oslabs-beta/react-pinpoint/tree/master/dockerfile-generator) 127 | 128 | ## FAQs 129 | 130 | #### Why does React Pinpoint only measure individual component render times? 131 | 132 | Since React has moved to using a React Fiber infrastructure, different component types are assumed to generate different trees. 133 | 134 | #### Why is the default render threshold 16ms? 135 | 136 | The recommended render time is [60 FPS](https://developers.google.com/web/fundamentals/performance/rendering). 137 | 138 | #### Does React pinpoint work in a headless browser? 139 | 140 | Yes! Due to the component render times being driven by component logic, a GUI is not needed to capture them. 141 | -------------------------------------------------------------------------------- /cicd-workflow/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/react-pinpoint/180dd46fa3828eaff524ec200fbac7477ee90af6/cicd-workflow/README.md -------------------------------------------------------------------------------- /cicd-workflow/github-action-generator.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-param-reassign */ 3 | const prompt = require('prompt'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | prompt.start(); 8 | 9 | /** 10 | * @description function to sanitize string 11 | */ 12 | const sanitizeString = str => { 13 | const sanitizedStr = str.replace(/[^a-z0-9.,_-]/gim, ''); 14 | return sanitizedStr.trim(); 15 | }; 16 | 17 | // github-action.yml file contents 18 | let githubActionFile = ''; 19 | 20 | /** 21 | * @description function to generate a docker-compose.yml file based on input parameters 22 | */ 23 | const generateGithubActionFile = parameters => { 24 | const {actionName} = parameters; 25 | if (typeof actionName !== 'string') { 26 | throw new Error('Error, input must be a string.'); 27 | } 28 | githubActionFile = `name: ${actionName} 29 | on: push 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | uses: actions/checkout@v2 35 | run: docker-compose-up --build --abort-on-container-exit`; 36 | }; 37 | 38 | /** 39 | * @description function to write a docker file for the app container and test container, and a docker-compose file to link them together 40 | */ 41 | const writeFiles = () => { 42 | const files = [{fileName: 'github-action.yml', contents: githubActionFile}]; 43 | files.forEach(file => fs.writeFileSync(path.resolve(__dirname, file.fileName), file.contents)); 44 | }; 45 | 46 | /** 47 | * @description function to log an error 48 | */ 49 | const onErr = err => { 50 | console.log(err); 51 | return 1; 52 | }; 53 | 54 | // schema to validate user input 55 | const schema = { 56 | properties: { 57 | actionName: { 58 | description: 'action name', 59 | pattern: /^[a-zA-Z-]+$/, 60 | message: 'action name must only contain letters or dashes and cannot be blank', 61 | required: true, 62 | }, 63 | }, 64 | }; 65 | 66 | prompt.get(schema, (err, result) => { 67 | if (err) { 68 | return onErr(err); 69 | } 70 | result.appName = sanitizeString(result.actionName); 71 | generateGithubActionFile(result); 72 | console.log(`success.`); 73 | return writeFiles(result); 74 | }); 75 | -------------------------------------------------------------------------------- /dockerfile-generator/README.md: -------------------------------------------------------------------------------- 1 | # dockerfile generator 2 | 3 | A script to generate docker files to be used with react-pinpoint and puppeteer js. 4 | 5 | ## Table of Contents 6 | 7 | - [Description](##Description) 8 | - [Prerequisites](##Prerequisites) 9 | - [Features](##Features) 10 | - [Usage](##Usage) 11 | - [Getting Started](###Getting-Started) 12 | - [User Inputs](###User-Inputs) 13 | - [Configure the url for react-pinpoint](###Configure-the-url-for-react-pinpoint) 14 | - [Build and run the docker containers](###Build-and-run-the-docker-containers) 15 | 16 | ## Description 17 | 18 | A script to generate Docker files to be used with react-pinpoint and puppeteer js. 19 | 20 | ## Prerequisites 21 | 22 | - [Docker](https://www.docker.com/) 23 | - [react-pinpoint](https://github.com/oslabs-beta/react-pinpoint) 24 | - [puppeteer](https://pptr.dev/) 25 | 26 | ## Features 27 | 28 | The script will create (in the same folder): 29 | 30 | - Dockerfile.app 31 | - Dockerfile.test 32 | - docker-compose.yml 33 | 34 | ## Usage 35 | 36 | ### Getting Started 37 | 38 | 1. Download `dockerfile-generator.js` and save it in the root folder of your app. 39 | 2. Run `node dockerfile-generator.js` to generate the files. 40 | 41 | ### User Inputs 42 | 43 | The script will prompt for 4 user inputs as follows: 44 | 45 | 1. `app name` - provide a name for your app, e.g. `webapp`. 46 | 2. `port` - provide a port number to host the app server, e.g. `5000`. The app server will be accessible at `http://webapp:5000` 47 | 3. `start script` - provide the start script used to serve the app server, e.g. `npm start` 48 | 4. `test script` - provide the test script used to run the tests, e.g. `npm test` 49 | 50 | ### Configure the url for react-pinpoint 51 | 52 | In the test file, replace `http://localhost:3000` with the `app name` and `port` provided, e.g. `http://webapp:5000` 53 | 54 | ```javascript 55 | beforeEach(async () => { 56 | browser = await puppeteer.launch({ 57 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 58 | }); 59 | page = await browser.newPage(); 60 | const url = 'http://webapp:5000'; 61 | const rootId = '#root'; 62 | await reactPinpoint.recordTest(page, url, rootId); 63 | }); 64 | ``` 65 | 66 | Full example test file below: 67 | 68 | ```javascript 69 | const puppeteer = require('puppeteer'); 70 | const reactPinpoint = require('react-pinpoint'); 71 | 72 | let browser, page; 73 | 74 | beforeEach(async () => { 75 | browser = await puppeteer.launch({ 76 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 77 | }); 78 | page = await browser.newPage(); 79 | const url = 'http://webapp:5000'; 80 | const rootId = '#root'; 81 | await reactPinpoint.recordTest(page, url, rootId); 82 | }); 83 | 84 | afterEach(async () => { 85 | const slowRenders = await reactPinpoint.reportTestResults(page, 16); 86 | console.log(`There are: ${slowRenders.length} 'slow renders.'`); 87 | await browser.close(); 88 | }); 89 | 90 | test('The checkbox should be checked.', async () => { 91 | await page.click('#MyCheckbox'); 92 | const result = await page.evaluate(() => { 93 | const myCheckbox = document.querySelector('#myCheckbox'); 94 | return myCheckbox.checked; 95 | }); 96 | expect(result).toBe(true); 97 | }); 98 | ``` 99 | 100 | ### Build and run the docker containers 101 | 102 | Run `docker-compose up --build` to build and run the docker containers. 103 | -------------------------------------------------------------------------------- /dockerfile-generator/dockerfile-generator.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-param-reassign */ 3 | const prompt = require('prompt'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | prompt.start(); 8 | 9 | // docker file contents for app container 10 | const appDockerFile = `FROM node:12-alpine 11 | WORKDIR /app 12 | COPY package-lock.json package.json ./ 13 | RUN npm ci 14 | COPY . .`; 15 | 16 | // docker file contents for puppeteer test container 17 | const testDockerFile = `FROM buildkite/puppeteer:5.2.1 18 | WORKDIR /tests 19 | COPY package-lock.json package.json ./ 20 | RUN npm ci 21 | COPY . .`; 22 | 23 | /** 24 | * @description function to sanitize string 25 | */ 26 | const sanitizeString = str => { 27 | const sanitizedStr = str.replace(/[^a-z0-9.,_-]/gim, ''); 28 | return sanitizedStr.trim(); 29 | }; 30 | 31 | // docker-compose.yml file contents 32 | let dockerComposeFile = ''; 33 | 34 | /** 35 | * @description function to generate a docker-compose.yml file based on input parameters 36 | */ 37 | const generateDockerComposeFile = parameters => { 38 | const {appName, port, startScript, testScript} = parameters; 39 | if (typeof appName !== 'string' || typeof startScript !== 'string' || typeof testScript !== 'string') { 40 | throw new Error('Error, input must be a string.'); 41 | } 42 | dockerComposeFile = `version: "3" 43 | services: 44 | tests: 45 | build: 46 | context: . 47 | dockerfile: Dockerfile.test 48 | command: bash -c "wait-for-it.sh ${appName}:${port} && ${testScript}" 49 | links: 50 | - ${appName} 51 | ${appName}: 52 | build: 53 | context: . 54 | dockerfile: Dockerfile.app 55 | command: ${startScript} 56 | tty: true 57 | expose: 58 | - "${port}"`; 59 | }; 60 | 61 | /** 62 | * @description function to write a docker file for the app container and test container, and a docker-compose file to link them together 63 | */ 64 | const writeFiles = () => { 65 | const files = [ 66 | {fileName: 'Dockerfile.app', contents: appDockerFile}, 67 | {fileName: 'Dockerfile.test', contents: testDockerFile}, 68 | {fileName: 'docker-compose.yml', contents: dockerComposeFile}, 69 | ]; 70 | files.forEach(file => fs.writeFileSync(path.resolve(__dirname, file.fileName), file.contents)); 71 | }; 72 | 73 | /** 74 | * @description function to log an error 75 | */ 76 | const onErr = err => { 77 | console.log(err); 78 | return 1; 79 | }; 80 | 81 | // schema to validate user input 82 | const schema = { 83 | properties: { 84 | appName: { 85 | description: 'app name', 86 | pattern: /^[a-zA-Z-]+$/, 87 | message: 'app name must only contain letters or dashes and cannot be blank', 88 | required: true, 89 | }, 90 | port: { 91 | description: 'port number', 92 | pattern: /^[0-9]*$/, 93 | message: 'port number must only contain numbers and cannot be blank', 94 | required: true, 95 | }, 96 | startScript: { 97 | description: 'start script', 98 | pattern: /^[a-zA-Z\s-]+$/, 99 | message: 'start script must only contain letters, spaces, or dashes and cannot be blank', 100 | required: true, 101 | }, 102 | testScript: { 103 | description: 'test script', 104 | pattern: /^[a-zA-Z\s-]+$/, 105 | message: 'test script must only contain letters, spaces, or dashes and cannot be blank', 106 | required: true, 107 | }, 108 | }, 109 | }; 110 | 111 | prompt.get(schema, (err, result) => { 112 | if (err) { 113 | return onErr(err); 114 | } 115 | result.appName = sanitizeString(result.appName); 116 | generateDockerComposeFile(result); 117 | console.log(`success. remember to change http://localhost in your puppeteer test file to http://${result.appName}`); 118 | return writeFiles(result); 119 | }); 120 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-jsdom', 3 | }; 4 | -------------------------------------------------------------------------------- /lib/bundle.main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | 5 | /* eslint-disable no-prototype-builtins */ 6 | 7 | /* eslint-disable no-case-declarations */ 8 | class TreeNode { 9 | // will add type after ;D 10 | constructor(fiberNode, uID) { 11 | this.uID = uID; 12 | const {elementType, selfBaseDuration, memoizedState, memoizedProps, effectTag, tag, ref, updateQueue, stateNode, type} = fiberNode; 13 | this.elementType = elementType; 14 | this.selfBaseDuration = selfBaseDuration; 15 | this.memoizedProps = memoizedProps; 16 | this.memoizedState = memoizedState; 17 | this.effectTag = effectTag; 18 | this.ref = ref; 19 | this.type = type; 20 | this.fiberName = getElementName(fiberNode); 21 | this.updateQueue = updateQueue; // seems to be replaced entirely and since it exists directly under a fiber node, it can't be modified. 22 | 23 | this.tag = tag; 24 | this.updateList = []; 25 | this.children = []; 26 | this.parent = null; 27 | this.stateNode = stateNode; 28 | 29 | if (tag === 0 && !stateNode && memoizedState) { 30 | // pass in "queue" obj to spy on dispatch func 31 | console.log('attaching a spy', memoizedState.queue); 32 | } 33 | } 34 | 35 | addChild(treeNode) { 36 | // remove other uneccessary properties 37 | this.child = treeNode; 38 | } 39 | 40 | addSibling(treeNode) { 41 | // if (!node) return; 42 | this.sibling = treeNode; 43 | } 44 | 45 | addParent(treeNode) { 46 | // if (!node) return; 47 | this.parent = treeNode; 48 | } 49 | 50 | toSerializable() { 51 | const newObj = {}; 52 | const omitList = [ 53 | 'memoizedProps', // currently working on serialization for this 54 | 'memoizedState', // and this as well 55 | 'updateList', 56 | 'updateQueue', 57 | 'ref', 58 | 'elementType', 59 | 'stateNode', // serialization needed for this? 60 | 'sibling', // maybe not needed 61 | 'type', // some circular references in here that we haven't accounted for 62 | ]; // transform each nested node to just ids where appropriate 63 | 64 | for (const key of Object.getOwnPropertyNames(this)) { 65 | if (omitList.indexOf(key) < 0) { 66 | switch (key) { 67 | case 'parent': 68 | newObj['parent_component_id'] = this[key] ? this[key].uID : this[key]; 69 | break; 70 | 71 | case 'fiberName': 72 | newObj['component_name'] = this[key]; 73 | break; 74 | 75 | case 'sibling': 76 | newObj['sibling_component_id'] = this[key].uID; 77 | break; 78 | 79 | case 'selfBaseDuration': 80 | newObj['self_base_duration'] = this[key]; 81 | break; 82 | 83 | case 'child': 84 | // probably not needed anymore, this prop seems to be redundant 85 | // newObj[`${key}ID`] = this[key].uID; 86 | break; 87 | 88 | case 'children': 89 | newObj[`children_ids`] = this[key].map(treeNode => treeNode.uID); 90 | break; 91 | 92 | case 'memoizedState': 93 | newObj['component_state'] = this[key]; 94 | break; 95 | 96 | case 'memoizedProps': 97 | if (this[key]) { 98 | newObj['component_props'] = this[key].hasOwnProperty('children') ? serializeMemoizedProps(this[key]) : this[key]; 99 | } else { 100 | newObj['component_props'] = this[key]; 101 | } // newObj["component_props"] = this[key]; 102 | 103 | break; 104 | 105 | case 'uID': 106 | newObj['component_id'] = this[key]; 107 | break; 108 | 109 | case 'stateNode': 110 | let value = null; 111 | 112 | if (this[key]) { 113 | if (this[key].tag === 5) { 114 | value = 'host component'; 115 | } else if (this[key].tag === 3) { 116 | value = 'host root'; 117 | } else { 118 | value = 'other type'; 119 | } 120 | } 121 | 122 | newObj['state_node'] = value; 123 | break; 124 | 125 | default: 126 | newObj[key] = this[key]; 127 | } 128 | } 129 | } 130 | 131 | return newObj; 132 | } 133 | } 134 | 135 | function getElementName(fiber) { 136 | let name = ''; 137 | 138 | switch (fiber.tag) { 139 | case 0: 140 | case 1: 141 | name = fiber.elementType.name || fiber.type.name; // somehow react lazy has a tag of 1, it seems to be grouped with the 142 | 143 | return name; 144 | 145 | case 3: 146 | name = 'Host Root'; 147 | return name; 148 | 149 | case 5: 150 | name = fiber.elementType; 151 | 152 | if (fiber.elementType.className) { 153 | name += `.${fiber.elementType.className}`; 154 | } else if (fiber.memoizedProps) { 155 | if (fiber.memoizedProps.className) { 156 | name += `.${fiber.memoizedProps.className}`; 157 | } else if (fiber.memoizedProps.id) { 158 | name += `.${fiber.memoizedProps.id}`; 159 | } 160 | } 161 | 162 | if (name.length > 10) { 163 | // truncate long name 164 | name = name.slice(0, 10); 165 | name += '...'; 166 | } 167 | 168 | return name; 169 | 170 | case 6: 171 | let textValue = fiber.stateNode.nodeValue; // just to help truncating long text value, exceeding 4 chars will get truncated. 172 | 173 | if (textValue.length > 10) { 174 | textValue = textValue.slice(0, 10); 175 | textValue = textValue.padEnd(3, '.'); 176 | } 177 | 178 | name = `text: ${textValue}`; 179 | return name; 180 | 181 | case 8: 182 | name = 'Strict Mode Zone'; 183 | return name; 184 | 185 | case 9: 186 | name = fiber.elementType._context.displayName; 187 | return name; 188 | 189 | case 10: 190 | name = 'Context'; // 191 | 192 | return name; 193 | 194 | case 11: 195 | name = fiber.elementType.displayName + '-' + `to:"${fiber.memoizedProps.to}"`; 196 | return name; 197 | 198 | case 13: 199 | name = 'Suspense Zone'; 200 | return name; 201 | 202 | case 16: 203 | name = 'Lazy - ' + fiber.elementType._result.name; 204 | return name; 205 | 206 | default: 207 | return `${typeof fiber.elementType !== 'string' ? fiber.elementType : 'unknown'}`; 208 | } 209 | } 210 | 211 | function serializeMemoizedProps(obj) { 212 | if (!obj) return null; // list of props to omit from the resulting object in return statement 213 | 214 | const omitList = ['props', '_owner', '_store', '_sef', '_source', '_self']; 215 | let newObj = null; // loop through each prop to check if they exist on omitList 216 | // if yes then skip, no then include in the object being returned; 217 | 218 | if (Array.isArray(obj)) { 219 | if (!newObj) newObj = []; 220 | 221 | for (let i = 0; i < obj.length; i++) { 222 | const nestedChild = {}; 223 | 224 | for (const key of Object.getOwnPropertyNames(obj[i])) { 225 | if (omitList.indexOf(key) < 0) { 226 | nestedChild[key] = obj[i][key]; 227 | } 228 | } 229 | 230 | newObj.push(nestedChild); 231 | } 232 | } else { 233 | for (const key of Object.getOwnPropertyNames(obj)) { 234 | if (omitList.indexOf(key) < 0) { 235 | if (!newObj) newObj = {}; 236 | 237 | if (typeof obj[key] === 'object') { 238 | newObj[key] = serializeMemoizedProps(obj[key]); 239 | } else if (typeof obj[key] === 'symbol') { 240 | newObj[key] = obj[key].toString(); 241 | } else { 242 | newObj[key] = obj[key]; 243 | } 244 | } 245 | } 246 | } 247 | 248 | return newObj; 249 | } 250 | 251 | let fiberMap = undefined; 252 | let processedFibers = undefined; 253 | 254 | class Tree { 255 | // stretch feature 256 | // a singleton reference 257 | // a singleton reference 258 | // uniqueId is used to identify a fiber to then help with counting re-renders 259 | // componentList 260 | constructor(rootNode, FiberMap, ProcessedFibers) { 261 | fiberMap = FiberMap; 262 | processedFibers = ProcessedFibers; 263 | this.uniqueId = fiberMap.size; 264 | this.componentList = []; 265 | this.effectList = []; 266 | this.root = null; 267 | this.processNode(rootNode, null); 268 | } 269 | 270 | processNode(fiberNode, previousTreeNode) { 271 | // id used to reference a fiber in fiberMap 272 | let id = undefined; // using a unique part of each fiber to identify it. 273 | // both current and alternate only 1 reference to this unique part 274 | // which we can use to uniquely identify a fiber node even in the case 275 | // of current and alternate switching per commit/ 276 | 277 | let uniquePart = undefined; // skipping special nodes like svg, path 278 | 279 | if (fiberNode.tag === 5) { 280 | if (fiberNode.elementType === 'svg' || fiberNode.elementType === 'path') return; 281 | } // unique part of a fiber node depends on its type. 282 | 283 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 284 | uniquePart = fiberNode.memoizedProps; 285 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 286 | uniquePart = fiberNode.elementType; 287 | } else if (fiberNode.tag === 3) { 288 | uniquePart = fiberNode.stateNode; 289 | } else if (fiberNode.tag === 7) { 290 | uniquePart = fiberNode; 291 | } else if (fiberNode.tag === 8) { 292 | uniquePart = fiberNode.memoizedProps; 293 | } else { 294 | uniquePart = fiberNode.stateNode; 295 | } // if this is a unique fiber (that both "current" and "alternate" fiber represents) 296 | // then add to the processedFiber to make sure we don't re-account this fiber. 297 | 298 | if (!processedFibers.has(uniquePart)) { 299 | id = this.uniqueId; 300 | this.uniqueId++; 301 | fiberMap.set(id, fiberNode); 302 | processedFibers.set(uniquePart, id); 303 | } else { 304 | id = processedFibers.get(uniquePart); 305 | } // If it's a HostRoot with a tag of 3 306 | // create a new TreeNode 307 | 308 | if (fiberNode.tag === 3 && !this.root) { 309 | this.root = new TreeNode(fiberNode, id); 310 | this.componentList.push(this.root); // push a copy 311 | 312 | if (fiberNode.child) { 313 | this.processNode(fiberNode.child, this.root); 314 | } 315 | } else { 316 | const newNode = new TreeNode(fiberNode, id); 317 | newNode.addParent(previousTreeNode); 318 | previousTreeNode.children.push(newNode); 319 | previousTreeNode.addChild(newNode); 320 | this.componentList.push(newNode); 321 | 322 | if (fiberNode.child) { 323 | this.processNode(fiberNode.child, newNode); 324 | } 325 | 326 | if (fiberNode.sibling) { 327 | this.processSiblingNode(fiberNode.sibling, newNode, previousTreeNode); 328 | } 329 | } 330 | } 331 | 332 | processSiblingNode(fiberNode, previousTreeNode, parentTreeNode) { 333 | let uniquePart = undefined; 334 | let id = undefined; 335 | 336 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 337 | uniquePart = fiberNode.memoizedProps; 338 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 339 | uniquePart = fiberNode.elementType; 340 | } else if (fiberNode.tag === 3) { 341 | uniquePart = fiberNode.stateNode; 342 | } else if (fiberNode.tag === 7) { 343 | uniquePart = fiberNode; 344 | } else if (fiberNode.tag === 8) { 345 | uniquePart = fiberNode.memoizedProps; 346 | } else { 347 | uniquePart = fiberNode.stateNode; 348 | } 349 | 350 | if (!processedFibers.has(uniquePart)) { 351 | id = this.uniqueId; 352 | this.uniqueId++; 353 | fiberMap.set(id, fiberNode); 354 | processedFibers.set(uniquePart, id); 355 | } else { 356 | id = processedFibers.get(uniquePart); 357 | } 358 | 359 | const newNode = new TreeNode(fiberNode, id); 360 | newNode.addParent(parentTreeNode); 361 | parentTreeNode.children.push(newNode); 362 | previousTreeNode.addSibling(newNode); 363 | this.componentList.push(newNode); 364 | 365 | if (fiberNode.child) { 366 | this.processNode(fiberNode.child, newNode); 367 | } 368 | 369 | if (fiberNode.sibling) { 370 | this.processSiblingNode(fiberNode.sibling, newNode, parentTreeNode); 371 | } 372 | } 373 | 374 | getCommitssOfComponent(name, serialize = false) { 375 | let componentList = []; 376 | componentList = this.componentList.filter(item => item.fiberName === name); 377 | 378 | if (serialize) { 379 | componentList = componentList.map(item => item.toSerializable()); 380 | } 381 | 382 | return {...this, componentList}; 383 | } // stretch feature, possible todo but needs extensive testing. 384 | 385 | setStateOfRender() { 386 | this.componentList.forEach(component => { 387 | if (component.tag === 1 && component.memoizedState) { 388 | console.log(component.stateNode); 389 | component.stateNode.setState({...component.memoizedState}); 390 | } 391 | }); 392 | } 393 | } 394 | 395 | class Observable { 396 | constructor() { 397 | this.observers = []; 398 | } 399 | 400 | subscribe(f) { 401 | this.observers.push(f); 402 | } 403 | 404 | unsubsribe(f) { 405 | this.observers = this.observers.filter(sub => sub !== f); 406 | } 407 | 408 | notify(data) { 409 | this.observers.forEach(observer => observer(data)); 410 | } 411 | } 412 | 413 | /* eslint-disable no-prototype-builtins */ 414 | let changes$1 = []; 415 | const processedFibers$1 = new WeakMap(); 416 | const fiberMap$1 = new Map(); 417 | 418 | function mountToReactRoot$1(reactRoot, projectID) { 419 | // Reset changes 420 | changes$1 = []; 421 | const changeObservable = new Observable(); 422 | 423 | function getSet(obj, propName) { 424 | const newPropName = `_${propName}`; 425 | obj[newPropName] = obj[propName]; 426 | Object.defineProperty(obj, propName, { 427 | get() { 428 | return this[newPropName]; 429 | }, 430 | 431 | set(newVal) { 432 | this[newPropName] = newVal; 433 | changes$1.push(new Tree(this[newPropName], fiberMap$1, processedFibers$1)); 434 | changeObservable.notify(changes$1); 435 | if (projectID) sendData(changes$1, projectID); 436 | }, 437 | }); 438 | } // Lift parent of react fibers tree 439 | 440 | const parent = reactRoot._reactRootContainer._internalRoot; 441 | changes$1.push(new Tree(parent.current, fiberMap$1, processedFibers$1)); 442 | if (projectID) sendData(changes$1, projectID); // Add listener to react fibers tree so changes can be recorded 443 | 444 | getSet(parent, 'current'); 445 | return { 446 | changes: changes$1, 447 | changeObserver: changeObservable, 448 | }; 449 | } 450 | 451 | function scrubCircularReferences(changes) { 452 | // loop through the different commits 453 | // for every commit check the componentList 454 | // scrub the circular references and leave the flat one there 455 | const scrubChanges = changes.map(commit => { 456 | return commit.componentList.map(component => { 457 | return component.toSerializable(); 458 | }); 459 | }); 460 | return scrubChanges; 461 | } 462 | 463 | function removeCircularRefs(changes) { 464 | // loop through the different commits 465 | // for every commit check the componentList 466 | // scrub the circular references and leave the flat one there 467 | const scrubChanges = changes.map(commit => { 468 | return commit.componentList.map(component => { 469 | return component.toSerializable(); 470 | }); 471 | }); 472 | return scrubChanges; 473 | } 474 | /** 475 | * 476 | * @param {number} threshold The rendering time to filter for. 477 | */ 478 | 479 | function getAllSlowComponentRenders$1(changes, threshold) { 480 | // referencing "changes" in the global scope 481 | const scrubChanges = removeCircularRefs(changes); // rework this so that we can use the pre-serialized data 482 | 483 | const result = scrubChanges.map(commit => { 484 | return commit.filter(component => component.self_base_duration >= threshold); 485 | }); 486 | return result; 487 | } // function checkTime(fiber, threshold) { 488 | // return fiber.selfBaseDuration > threshold; 489 | // } 490 | // function getComponentRenderTime(componentName) { 491 | // console.log("changes", changes); 492 | // console.log("component name", componentName); 493 | // return "what"; 494 | // } 495 | 496 | function getTotalCommitCount(changes, name, storageType) { 497 | const componentStore = new Map(); 498 | let filteredChanges = []; 499 | 500 | if (name) { 501 | filteredChanges = changes.map(commit => { 502 | return commit.getCommitssOfComponent(name); 503 | }); 504 | } else { 505 | filteredChanges = changes; 506 | } // looping through each commit's componentList to tally up the renderCount 507 | 508 | filteredChanges.forEach((commit, commitIndex) => { 509 | commit.componentList.forEach((component, componentIndex) => { 510 | if (!componentStore.has(component.uID)) { 511 | componentStore.set(component.uID, { 512 | component, 513 | renderCount: 1, 514 | }); 515 | } else { 516 | if (didFiberRender(changes[commitIndex ? commitIndex - 1 : 0].componentList[componentIndex], component)) { 517 | componentStore.get(component.uID).renderCount += 1; 518 | } 519 | } 520 | }); 521 | }); 522 | let result = []; 523 | 524 | if (storageType && storageType === 'array') { 525 | result = Array.from(componentStore.values()); 526 | } else { 527 | result = componentStore; 528 | } 529 | 530 | return result; 531 | } 532 | 533 | function didFiberRender(prevFiber, nextFiber) { 534 | switch (nextFiber.tag) { 535 | case 0: 536 | case 1: 537 | case 3: 538 | // case 5: 539 | return (nextFiber.effectTag & 1) === 1; 540 | 541 | default: 542 | return ( 543 | prevFiber.memoizedProps !== nextFiber.memoizedProps || 544 | prevFiber.memoizedState !== nextFiber.memoizedState || 545 | prevFiber.ref !== nextFiber.ref 546 | ); 547 | } 548 | } 549 | 550 | async function sendData(changes, projectID) { 551 | if (!projectID) return; 552 | const data = scrubCircularReferences(changes); 553 | console.log(data); 554 | const request = await fetch(`https://react-pinpoint-api.herokuapp.com/api/commit/${projectID}`, { 555 | method: 'POST', 556 | mode: 'cors', 557 | credentials: 'include', 558 | headers: { 559 | 'Content-Type': 'application/json', 560 | }, 561 | body: JSON.stringify({ 562 | changes: data[data.length - 1], 563 | }), 564 | }); 565 | const response = await request.json(); 566 | console.log('response from server', response); 567 | } 568 | 569 | var ReactPP = { 570 | mountToReactRoot: mountToReactRoot$1, 571 | getAllSlowComponentRenders: getAllSlowComponentRenders$1, 572 | getTotalCommitCount, 573 | scrubCircularReferences, 574 | }; 575 | 576 | // const {mountToReactRoot, getAllSlowComponentRenders, getTotalCommitCount, scrubCircularReferences} = require('./bundle.puppeteer.js'); // since generated file is in lib folder 577 | 578 | async function record(page, url, rootIdString, projectID) { 579 | // Mock devtools hook so react will record fibers 580 | // Must exist before react runs 581 | await page.evaluateOnNewDocument(() => { 582 | window['__REACT_DEVTOOLS_GLOBAL_HOOK__'] = {}; 583 | }); // Load url and inject code to page 584 | 585 | await page.goto(url); 586 | await page.addScriptTag({ 587 | path: path.join(__dirname, './bundle.puppeteer.js'), 588 | }); // Start recording changes 589 | 590 | await page.evaluate( 591 | (rootIdString, projectID) => { 592 | const root = document.querySelector(rootIdString); // @ts-ignore 593 | 594 | mountToReactRoot(root, projectID); 595 | }, 596 | rootIdString, 597 | projectID ? projectID : null, 598 | ); 599 | return page; 600 | } 601 | 602 | // workaround since we're eval-ing this in browser context 603 | async function report(page, threshold = 0) { 604 | // Return results of local state that exceeds threshold 605 | const slowRenders = await page.evaluate(async threshold => { 606 | // @ts-ignore 607 | const result = getAllSlowComponentRenders(changes, threshold); 608 | return JSON.stringify(result); 609 | }, threshold); 610 | return JSON.parse(slowRenders); 611 | } 612 | 613 | var index = { 614 | record, 615 | report, 616 | mountToReactRoot: ReactPP.mountToReactRoot, 617 | getAllSlowComponentRenders: ReactPP.getAllSlowComponentRenders, 618 | getTotalCommitCount: ReactPP.getTotalCommitCount, 619 | scrubCircularReferences: ReactPP.scrubCircularReferences, 620 | }; 621 | 622 | module.exports = index; 623 | -------------------------------------------------------------------------------- /lib/bundle.puppeteer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable no-prototype-builtins */ 4 | 5 | /* eslint-disable no-case-declarations */ 6 | class TreeNode { 7 | // will add type after ;D 8 | constructor(fiberNode, uID) { 9 | this.uID = uID; 10 | const {elementType, selfBaseDuration, memoizedState, memoizedProps, effectTag, tag, ref, updateQueue, stateNode, type} = fiberNode; 11 | this.elementType = elementType; 12 | this.selfBaseDuration = selfBaseDuration; 13 | this.memoizedProps = memoizedProps; 14 | this.memoizedState = memoizedState; 15 | this.effectTag = effectTag; 16 | this.ref = ref; 17 | this.type = type; 18 | this.fiberName = getElementName(fiberNode); 19 | this.updateQueue = updateQueue; // seems to be replaced entirely and since it exists directly under a fiber node, it can't be modified. 20 | 21 | this.tag = tag; 22 | this.updateList = []; 23 | this.children = []; 24 | this.parent = null; 25 | this.stateNode = stateNode; 26 | 27 | if (tag === 0 && !stateNode && memoizedState) { 28 | // pass in "queue" obj to spy on dispatch func 29 | console.log('attaching a spy', memoizedState.queue); 30 | } 31 | } 32 | 33 | addChild(treeNode) { 34 | // remove other uneccessary properties 35 | this.child = treeNode; 36 | } 37 | 38 | addSibling(treeNode) { 39 | // if (!node) return; 40 | this.sibling = treeNode; 41 | } 42 | 43 | addParent(treeNode) { 44 | // if (!node) return; 45 | this.parent = treeNode; 46 | } 47 | 48 | toSerializable() { 49 | const newObj = {}; 50 | const omitList = [ 51 | 'memoizedProps', // currently working on serialization for this 52 | 'memoizedState', // and this as well 53 | 'updateList', 54 | 'updateQueue', 55 | 'ref', 56 | 'elementType', 57 | 'stateNode', // serialization needed for this? 58 | 'sibling', // maybe not needed 59 | 'type', // some circular references in here that we haven't accounted for 60 | ]; // transform each nested node to just ids where appropriate 61 | 62 | for (const key of Object.getOwnPropertyNames(this)) { 63 | if (omitList.indexOf(key) < 0) { 64 | switch (key) { 65 | case 'parent': 66 | newObj['parent_component_id'] = this[key] ? this[key].uID : this[key]; 67 | break; 68 | 69 | case 'fiberName': 70 | newObj['component_name'] = this[key]; 71 | break; 72 | 73 | case 'sibling': 74 | newObj['sibling_component_id'] = this[key].uID; 75 | break; 76 | 77 | case 'selfBaseDuration': 78 | newObj['self_base_duration'] = this[key]; 79 | break; 80 | 81 | case 'child': 82 | // probably not needed anymore, this prop seems to be redundant 83 | // newObj[`${key}ID`] = this[key].uID; 84 | break; 85 | 86 | case 'children': 87 | newObj[`children_ids`] = this[key].map(treeNode => treeNode.uID); 88 | break; 89 | 90 | case 'memoizedState': 91 | newObj['component_state'] = this[key]; 92 | break; 93 | 94 | case 'memoizedProps': 95 | if (this[key]) { 96 | newObj['component_props'] = this[key].hasOwnProperty('children') ? serializeMemoizedProps(this[key]) : this[key]; 97 | } else { 98 | newObj['component_props'] = this[key]; 99 | } // newObj["component_props"] = this[key]; 100 | 101 | break; 102 | 103 | case 'uID': 104 | newObj['component_id'] = this[key]; 105 | break; 106 | 107 | case 'stateNode': 108 | let value = null; 109 | 110 | if (this[key]) { 111 | if (this[key].tag === 5) { 112 | value = 'host component'; 113 | } else if (this[key].tag === 3) { 114 | value = 'host root'; 115 | } else { 116 | value = 'other type'; 117 | } 118 | } 119 | 120 | newObj['state_node'] = value; 121 | break; 122 | 123 | default: 124 | newObj[key] = this[key]; 125 | } 126 | } 127 | } 128 | 129 | return newObj; 130 | } 131 | } 132 | 133 | function getElementName(fiber) { 134 | let name = ''; 135 | 136 | switch (fiber.tag) { 137 | case 0: 138 | case 1: 139 | name = fiber.elementType.name || fiber.type.name; // somehow react lazy has a tag of 1, it seems to be grouped with the 140 | 141 | return name; 142 | 143 | case 3: 144 | name = 'Host Root'; 145 | return name; 146 | 147 | case 5: 148 | name = fiber.elementType; 149 | 150 | if (fiber.elementType.className) { 151 | name += `.${fiber.elementType.className}`; 152 | } else if (fiber.memoizedProps) { 153 | if (fiber.memoizedProps.className) { 154 | name += `.${fiber.memoizedProps.className}`; 155 | } else if (fiber.memoizedProps.id) { 156 | name += `.${fiber.memoizedProps.id}`; 157 | } 158 | } 159 | 160 | if (name.length > 10) { 161 | // truncate long name 162 | name = name.slice(0, 10); 163 | name += '...'; 164 | } 165 | 166 | return name; 167 | 168 | case 6: 169 | let textValue = fiber.stateNode.nodeValue; // just to help truncating long text value, exceeding 4 chars will get truncated. 170 | 171 | if (textValue.length > 10) { 172 | textValue = textValue.slice(0, 10); 173 | textValue = textValue.padEnd(3, '.'); 174 | } 175 | 176 | name = `text: ${textValue}`; 177 | return name; 178 | 179 | case 8: 180 | name = 'Strict Mode Zone'; 181 | return name; 182 | 183 | case 9: 184 | name = fiber.elementType._context.displayName; 185 | return name; 186 | 187 | case 10: 188 | name = 'Context'; // 189 | 190 | return name; 191 | 192 | case 11: 193 | name = fiber.elementType.displayName + '-' + `to:"${fiber.memoizedProps.to}"`; 194 | return name; 195 | 196 | case 13: 197 | name = 'Suspense Zone'; 198 | return name; 199 | 200 | case 16: 201 | name = 'Lazy - ' + fiber.elementType._result.name; 202 | return name; 203 | 204 | default: 205 | return `${typeof fiber.elementType !== 'string' ? fiber.elementType : 'unknown'}`; 206 | } 207 | } 208 | 209 | function serializeMemoizedProps(obj) { 210 | if (!obj) return null; // list of props to omit from the resulting object in return statement 211 | 212 | const omitList = ['props', '_owner', '_store', '_sef', '_source', '_self']; 213 | let newObj = null; // loop through each prop to check if they exist on omitList 214 | // if yes then skip, no then include in the object being returned; 215 | 216 | if (Array.isArray(obj)) { 217 | if (!newObj) newObj = []; 218 | 219 | for (let i = 0; i < obj.length; i++) { 220 | const nestedChild = {}; 221 | 222 | for (const key of Object.getOwnPropertyNames(obj[i])) { 223 | if (omitList.indexOf(key) < 0) { 224 | nestedChild[key] = obj[i][key]; 225 | } 226 | } 227 | 228 | newObj.push(nestedChild); 229 | } 230 | } else { 231 | for (const key of Object.getOwnPropertyNames(obj)) { 232 | if (omitList.indexOf(key) < 0) { 233 | if (!newObj) newObj = {}; 234 | 235 | if (typeof obj[key] === 'object') { 236 | newObj[key] = serializeMemoizedProps(obj[key]); 237 | } else if (typeof obj[key] === 'symbol') { 238 | newObj[key] = obj[key].toString(); 239 | } else { 240 | newObj[key] = obj[key]; 241 | } 242 | } 243 | } 244 | } 245 | 246 | return newObj; 247 | } 248 | 249 | let fiberMap = undefined; 250 | let processedFibers = undefined; 251 | 252 | class Tree { 253 | // stretch feature 254 | // a singleton reference 255 | // a singleton reference 256 | // uniqueId is used to identify a fiber to then help with counting re-renders 257 | // componentList 258 | constructor(rootNode, FiberMap, ProcessedFibers) { 259 | fiberMap = FiberMap; 260 | processedFibers = ProcessedFibers; 261 | this.uniqueId = fiberMap.size; 262 | this.componentList = []; 263 | this.effectList = []; 264 | this.root = null; 265 | this.processNode(rootNode, null); 266 | } 267 | 268 | processNode(fiberNode, previousTreeNode) { 269 | // id used to reference a fiber in fiberMap 270 | let id = undefined; // using a unique part of each fiber to identify it. 271 | // both current and alternate only 1 reference to this unique part 272 | // which we can use to uniquely identify a fiber node even in the case 273 | // of current and alternate switching per commit/ 274 | 275 | let uniquePart = undefined; // skipping special nodes like svg, path 276 | 277 | if (fiberNode.tag === 5) { 278 | if (fiberNode.elementType === 'svg' || fiberNode.elementType === 'path') return; 279 | } // unique part of a fiber node depends on its type. 280 | 281 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 282 | uniquePart = fiberNode.memoizedProps; 283 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 284 | uniquePart = fiberNode.elementType; 285 | } else if (fiberNode.tag === 3) { 286 | uniquePart = fiberNode.stateNode; 287 | } else if (fiberNode.tag === 7) { 288 | uniquePart = fiberNode; 289 | } else if (fiberNode.tag === 8) { 290 | uniquePart = fiberNode.memoizedProps; 291 | } else { 292 | uniquePart = fiberNode.stateNode; 293 | } // if this is a unique fiber (that both "current" and "alternate" fiber represents) 294 | // then add to the processedFiber to make sure we don't re-account this fiber. 295 | 296 | if (!processedFibers.has(uniquePart)) { 297 | id = this.uniqueId; 298 | this.uniqueId++; 299 | fiberMap.set(id, fiberNode); 300 | processedFibers.set(uniquePart, id); 301 | } else { 302 | id = processedFibers.get(uniquePart); 303 | } // If it's a HostRoot with a tag of 3 304 | // create a new TreeNode 305 | 306 | if (fiberNode.tag === 3 && !this.root) { 307 | this.root = new TreeNode(fiberNode, id); 308 | this.componentList.push(this.root); // push a copy 309 | 310 | if (fiberNode.child) { 311 | this.processNode(fiberNode.child, this.root); 312 | } 313 | } else { 314 | const newNode = new TreeNode(fiberNode, id); 315 | newNode.addParent(previousTreeNode); 316 | previousTreeNode.children.push(newNode); 317 | previousTreeNode.addChild(newNode); 318 | this.componentList.push(newNode); 319 | 320 | if (fiberNode.child) { 321 | this.processNode(fiberNode.child, newNode); 322 | } 323 | 324 | if (fiberNode.sibling) { 325 | this.processSiblingNode(fiberNode.sibling, newNode, previousTreeNode); 326 | } 327 | } 328 | } 329 | 330 | processSiblingNode(fiberNode, previousTreeNode, parentTreeNode) { 331 | let uniquePart = undefined; 332 | let id = undefined; 333 | 334 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 335 | uniquePart = fiberNode.memoizedProps; 336 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 337 | uniquePart = fiberNode.elementType; 338 | } else if (fiberNode.tag === 3) { 339 | uniquePart = fiberNode.stateNode; 340 | } else if (fiberNode.tag === 7) { 341 | uniquePart = fiberNode; 342 | } else if (fiberNode.tag === 8) { 343 | uniquePart = fiberNode.memoizedProps; 344 | } else { 345 | uniquePart = fiberNode.stateNode; 346 | } 347 | 348 | if (!processedFibers.has(uniquePart)) { 349 | id = this.uniqueId; 350 | this.uniqueId++; 351 | fiberMap.set(id, fiberNode); 352 | processedFibers.set(uniquePart, id); 353 | } else { 354 | id = processedFibers.get(uniquePart); 355 | } 356 | 357 | const newNode = new TreeNode(fiberNode, id); 358 | newNode.addParent(parentTreeNode); 359 | parentTreeNode.children.push(newNode); 360 | previousTreeNode.addSibling(newNode); 361 | this.componentList.push(newNode); 362 | 363 | if (fiberNode.child) { 364 | this.processNode(fiberNode.child, newNode); 365 | } 366 | 367 | if (fiberNode.sibling) { 368 | this.processSiblingNode(fiberNode.sibling, newNode, parentTreeNode); 369 | } 370 | } 371 | 372 | getCommitssOfComponent(name, serialize = false) { 373 | let componentList = []; 374 | componentList = this.componentList.filter(item => item.fiberName === name); 375 | 376 | if (serialize) { 377 | componentList = componentList.map(item => item.toSerializable()); 378 | } 379 | 380 | return {...this, componentList}; 381 | } // stretch feature, possible todo but needs extensive testing. 382 | 383 | setStateOfRender() { 384 | this.componentList.forEach(component => { 385 | if (component.tag === 1 && component.memoizedState) { 386 | console.log(component.stateNode); 387 | component.stateNode.setState({...component.memoizedState}); 388 | } 389 | }); 390 | } 391 | } 392 | 393 | class Observable { 394 | constructor() { 395 | this.observers = []; 396 | } 397 | 398 | subscribe(f) { 399 | this.observers.push(f); 400 | } 401 | 402 | unsubsribe(f) { 403 | this.observers = this.observers.filter(sub => sub !== f); 404 | } 405 | 406 | notify(data) { 407 | this.observers.forEach(observer => observer(data)); 408 | } 409 | } 410 | 411 | /* eslint-disable no-prototype-builtins */ 412 | let changes = []; 413 | const processedFibers$1 = new WeakMap(); 414 | const fiberMap$1 = new Map(); 415 | 416 | function mountToReactRoot(reactRoot, projectID) { 417 | // Reset changes 418 | changes = []; 419 | const changeObservable = new Observable(); 420 | 421 | function getSet(obj, propName) { 422 | const newPropName = `_${propName}`; 423 | obj[newPropName] = obj[propName]; 424 | Object.defineProperty(obj, propName, { 425 | get() { 426 | return this[newPropName]; 427 | }, 428 | 429 | set(newVal) { 430 | this[newPropName] = newVal; 431 | changes.push(new Tree(this[newPropName], fiberMap$1, processedFibers$1)); 432 | changeObservable.notify(changes); 433 | if (projectID) sendData(changes, projectID); 434 | }, 435 | }); 436 | } // Lift parent of react fibers tree 437 | 438 | const parent = reactRoot._reactRootContainer._internalRoot; 439 | changes.push(new Tree(parent.current, fiberMap$1, processedFibers$1)); 440 | if (projectID) sendData(changes, projectID); // Add listener to react fibers tree so changes can be recorded 441 | 442 | getSet(parent, 'current'); 443 | return { 444 | changes, 445 | changeObserver: changeObservable, 446 | }; 447 | } 448 | 449 | function scrubCircularReferences(changes) { 450 | // loop through the different commits 451 | // for every commit check the componentList 452 | // scrub the circular references and leave the flat one there 453 | const scrubChanges = changes.map(commit => { 454 | return commit.componentList.map(component => { 455 | return component.toSerializable(); 456 | }); 457 | }); 458 | return scrubChanges; 459 | } 460 | 461 | function removeCircularRefs(changes) { 462 | // loop through the different commits 463 | // for every commit check the componentList 464 | // scrub the circular references and leave the flat one there 465 | const scrubChanges = changes.map(commit => { 466 | return commit.componentList.map(component => { 467 | return component.toSerializable(); 468 | }); 469 | }); 470 | return scrubChanges; 471 | } 472 | /** 473 | * 474 | * @param {number} threshold The rendering time to filter for. 475 | */ 476 | 477 | function getAllSlowComponentRenders(changes, threshold) { 478 | // referencing "changes" in the global scope 479 | const scrubChanges = removeCircularRefs(changes); // rework this so that we can use the pre-serialized data 480 | 481 | const result = scrubChanges.map(commit => { 482 | return commit.filter(component => component.self_base_duration >= threshold); 483 | }); 484 | return result; 485 | } // function checkTime(fiber, threshold) { 486 | // return fiber.selfBaseDuration > threshold; 487 | // } 488 | // function getComponentRenderTime(componentName) { 489 | // console.log("changes", changes); 490 | // console.log("component name", componentName); 491 | // return "what"; 492 | // } 493 | 494 | function getTotalCommitCount(changes, name, storageType) { 495 | const componentStore = new Map(); 496 | let filteredChanges = []; 497 | 498 | if (name) { 499 | filteredChanges = changes.map(commit => { 500 | return commit.getCommitssOfComponent(name); 501 | }); 502 | } else { 503 | filteredChanges = changes; 504 | } // looping through each commit's componentList to tally up the renderCount 505 | 506 | filteredChanges.forEach((commit, commitIndex) => { 507 | commit.componentList.forEach((component, componentIndex) => { 508 | if (!componentStore.has(component.uID)) { 509 | componentStore.set(component.uID, { 510 | component, 511 | renderCount: 1, 512 | }); 513 | } else { 514 | if (didFiberRender(changes[commitIndex ? commitIndex - 1 : 0].componentList[componentIndex], component)) { 515 | componentStore.get(component.uID).renderCount += 1; 516 | } 517 | } 518 | }); 519 | }); 520 | let result = []; 521 | 522 | if (storageType && storageType === 'array') { 523 | result = Array.from(componentStore.values()); 524 | } else { 525 | result = componentStore; 526 | } 527 | 528 | return result; 529 | } 530 | 531 | function didFiberRender(prevFiber, nextFiber) { 532 | switch (nextFiber.tag) { 533 | case 0: 534 | case 1: 535 | case 3: 536 | // case 5: 537 | return (nextFiber.effectTag & 1) === 1; 538 | 539 | default: 540 | return ( 541 | prevFiber.memoizedProps !== nextFiber.memoizedProps || 542 | prevFiber.memoizedState !== nextFiber.memoizedState || 543 | prevFiber.ref !== nextFiber.ref 544 | ); 545 | } 546 | } 547 | 548 | async function sendData(changes, projectID) { 549 | if (!projectID) return; 550 | const data = scrubCircularReferences(changes); 551 | console.log(data); 552 | const request = await fetch(`https://react-pinpoint-api.herokuapp.com/api/commit/${projectID}`, { 553 | method: 'POST', 554 | mode: 'cors', 555 | credentials: 'include', 556 | headers: { 557 | 'Content-Type': 'application/json', 558 | }, 559 | body: JSON.stringify({ 560 | changes: data[data.length - 1], 561 | }), 562 | }); 563 | const response = await request.json(); 564 | console.log('response from server', response); 565 | } 566 | 567 | var utils = { 568 | mountToReactRoot, 569 | getAllSlowComponentRenders, 570 | getTotalCommitCount, 571 | scrubCircularReferences, 572 | }; 573 | 574 | module.exports = utils; 575 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pinpoint", 3 | "version": "1.0.6", 4 | "description": "React Pinpoint is an open source utility library for measuring React component render times.", 5 | "main": "lib/bundle.main.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "scripts": { 10 | "build": "babel src --extensions .js,.ts,.tsx --out-dir dist", 11 | "build:rollup": "rollup --config", 12 | "lint": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .", 13 | "check-types": "tsc", 14 | "prettier": "prettier --ignore-path .gitignore --write \"**/*.+(js|json|ts)\"", 15 | "format": "npm run prettier -- --write", 16 | "check-format": "npm run prettier -- --list-different", 17 | "validate": "npm-run-all --parallel check-types check-format lint build", 18 | "test": "jest" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/oslabs-beta/react-pinpoint.git" 23 | }, 24 | "author": "React Pinpoint", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/oslabs-beta/react-pinpoint/issues" 28 | }, 29 | "keywords": [ 30 | "react-fiber" 31 | ], 32 | "homepage": "https://github.com/oslabs-beta/react-pinpoint#readme", 33 | "devDependencies": { 34 | "@babel/cli": "^7.10.5", 35 | "@babel/core": "^7.11.1", 36 | "@babel/plugin-transform-runtime": "^7.11.5", 37 | "@babel/preset-env": "^7.11.0", 38 | "@babel/preset-react": "^7.10.4", 39 | "@babel/preset-typescript": "^7.10.4", 40 | "@rollup/plugin-babel": "^5.2.1", 41 | "@rollup/plugin-commonjs": "^15.1.0", 42 | "@rollup/plugin-node-resolve": "^9.0.0", 43 | "@rollup/plugin-typescript": "^6.0.0", 44 | "@types/node": "^14.6.0", 45 | "@typescript-eslint/eslint-plugin": "^3.9.1", 46 | "@typescript-eslint/parser": "^3.9.1", 47 | "eslint": "7.5.0", 48 | "eslint-config-prettier": "^6.11.0", 49 | "eslint-plugin-jest": "^23.20.0", 50 | "eslint-plugin-react": "^7.20.6", 51 | "husky": "^4.2.5", 52 | "jest": "26.1.0", 53 | "lint-staged": "^10.2.11", 54 | "npm-run-all": "^4.1.5", 55 | "prettier": "^2.0.5", 56 | "react": "^16.13.1", 57 | "react-test-renderer": "^16.13.1", 58 | "rollup": "^2.28.2" 59 | }, 60 | "dependencies": { 61 | "@babel/runtime": "^7.11.2", 62 | "prompt": "^1.0.0", 63 | "typescript": "^3.9.7" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // import path from "path"; 2 | import babel from '@rollup/plugin-babel'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | 6 | const extensions = ['.ts', '.js']; 7 | 8 | export default [ 9 | { 10 | input: './src/utils/utils.ts', 11 | output: { 12 | file: 'lib/bundle.puppeteer.js', 13 | exports: 'default', 14 | format: 'cjs', 15 | }, 16 | plugins: [ 17 | resolve({ 18 | jsnext: true, 19 | extensions, 20 | }), 21 | commonjs({ 22 | include: 'node_modules/**', 23 | }), 24 | babel({ 25 | extensions, 26 | presets: ['@babel/preset-typescript'], 27 | exclude: /node_modules/, 28 | }), 29 | ], 30 | }, 31 | { 32 | input: './src/index.ts', 33 | output: { 34 | file: 'lib/bundle.main.js', 35 | exports: 'default', 36 | format: 'cjs', 37 | }, 38 | plugins: [ 39 | resolve({ 40 | jsnext: true, 41 | extensions, 42 | }), 43 | commonjs({ 44 | include: 'node_modules/**', 45 | }), 46 | babel({ 47 | extensions, 48 | babelHelpers: 'bundled', 49 | presets: ['@babel/preset-typescript'], 50 | exclude: /node_modules/, 51 | }), 52 | ], 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /src/__test__/index.test.js: -------------------------------------------------------------------------------- 1 | it('first', () => { 2 | expect(true).toBe(true); 3 | }); 4 | -------------------------------------------------------------------------------- /src/dockerfile-generator/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-param-reassign */ 3 | const prompt = require('prompt'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | prompt.start(); 8 | 9 | // docker file contents for app container 10 | const appDockerFile = `FROM node:12-alpine 11 | WORKDIR /app 12 | COPY package-lock.json package.json ./ 13 | RUN npm ci 14 | COPY . .`; 15 | 16 | // docker file contents for puppeteer test container 17 | const testDockerFile = `FROM buildkite/puppeteer:5.2.1 18 | WORKDIR /tests 19 | COPY package-lock.json package.json ./ 20 | RUN npm ci 21 | COPY . .`; 22 | 23 | /** 24 | * @description function to sanitize string 25 | */ 26 | const sanitizeString = str => { 27 | const sanitizedStr = str.replace(/[^a-z0-9.,_-]/gim, ''); 28 | return sanitizedStr.trim(); 29 | }; 30 | 31 | // docker-compose.yml file contents 32 | let dockerComposeFile = ''; 33 | 34 | /** 35 | * @description function to generate a docker-compose.yml file based on input parameters 36 | */ 37 | const generateDockerComposeFile = parameters => { 38 | const {appName, port, startScript, testScript} = parameters; 39 | if (typeof appName !== 'string' || typeof startScript !== 'string' || typeof testScript !== 'string') { 40 | throw new Error('Error, input must be a string.'); 41 | } 42 | dockerComposeFile = `version: "3" 43 | services: 44 | tests: 45 | build: 46 | context: . 47 | dockerfile: Dockerfile.test 48 | command: bash -c "wait-for-it.sh ${appName}:${port} && ${testScript}" 49 | links: 50 | - ${appName} 51 | ${appName}: 52 | build: 53 | context: . 54 | dockerfile: Dockerfile.app 55 | command: ${startScript} 56 | tty: true 57 | expose: 58 | - "${port}"`; 59 | }; 60 | 61 | /** 62 | * @description function to write a docker file for the app container and test container, and a docker-compose file to link them together 63 | */ 64 | const writeFiles = () => { 65 | const files = [ 66 | {fileName: 'Dockerfile.app', contents: appDockerFile}, 67 | {fileName: 'Dockerfile.test', contents: testDockerFile}, 68 | {fileName: 'docker-compose.yml', contents: dockerComposeFile}, 69 | ]; 70 | files.forEach(file => fs.writeFileSync(path.resolve(__dirname, file.fileName), file.contents)); 71 | }; 72 | 73 | /** 74 | * @description function to log an error 75 | */ 76 | const onErr = err => { 77 | console.log(err); 78 | return 1; 79 | }; 80 | 81 | // schema to validate user input 82 | const schema = { 83 | properties: { 84 | appName: { 85 | description: 'app name', 86 | pattern: /^[a-zA-Z-]+$/, 87 | message: 'app name must only contain letters or dashes and cannot be blank', 88 | required: true, 89 | }, 90 | port: { 91 | description: 'port number', 92 | pattern: /^[0-9]*$/, 93 | message: 'port number must only contain numbers and cannot be blank', 94 | required: true, 95 | }, 96 | startScript: { 97 | description: 'start script', 98 | pattern: /^[a-zA-Z\s-]+$/, 99 | message: 'start script must only contain letters, spaces, or dashes and cannot be blank', 100 | required: true, 101 | }, 102 | testScript: { 103 | description: 'test script', 104 | pattern: /^[a-zA-Z\s-]+$/, 105 | message: 'test script must only contain letters, spaces, or dashes and cannot be blank', 106 | required: true, 107 | }, 108 | }, 109 | }; 110 | 111 | prompt.get(schema, (err, result) => { 112 | if (err) { 113 | return onErr(err); 114 | } 115 | result.appName = sanitizeString(result.appName); 116 | generateDockerComposeFile(result); 117 | console.log(`success. remember to change http://localhost in your puppeteer test file to http://${result.appName}`); 118 | return writeFiles(result); 119 | }); 120 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import ReactPP from './utils/utils'; 3 | // const path = require('path'); 4 | // const {mountToReactRoot, getAllSlowComponentRenders, getTotalCommitCount, scrubCircularReferences} = require('./bundle.puppeteer.js'); // since generated file is in lib folder 5 | 6 | async function record(page, url: string, rootIdString: string, projectID?: string) { 7 | // Mock devtools hook so react will record fibers 8 | // Must exist before react runs 9 | await page.evaluateOnNewDocument(() => { 10 | window['__REACT_DEVTOOLS_GLOBAL_HOOK__'] = {}; 11 | }); 12 | 13 | // Load url and inject code to page 14 | await page.goto(url); 15 | await page.addScriptTag({ 16 | path: path.join(__dirname, './bundle.puppeteer.js'), 17 | }); 18 | 19 | // Start recording changes 20 | await page.evaluate( 21 | (rootIdString, projectID) => { 22 | const root = document.querySelector(rootIdString); 23 | // @ts-ignore 24 | mountToReactRoot(root, projectID); 25 | }, 26 | rootIdString, 27 | projectID ? projectID : null, 28 | ); 29 | 30 | return page; 31 | } 32 | 33 | declare const changes; // workaround since we're eval-ing this in browser context 34 | async function report(page, threshold = 0) { 35 | // Return results of local state that exceeds threshold 36 | const slowRenders = await page.evaluate(async threshold => { 37 | // @ts-ignore 38 | const result = getAllSlowComponentRenders(changes, threshold); 39 | return JSON.stringify(result); 40 | }, threshold); 41 | 42 | return JSON.parse(slowRenders); 43 | } 44 | 45 | async function reportAll() { 46 | // Return global state 47 | } 48 | 49 | // module.exports = {record, report, mountToReactRoot, getAllSlowComponentRenders, getTotalCommitCount, scrubCircularReferences}; 50 | export default { 51 | record, 52 | report, 53 | mountToReactRoot: ReactPP.mountToReactRoot, 54 | getAllSlowComponentRenders: ReactPP.getAllSlowComponentRenders, 55 | getTotalCommitCount: ReactPP.getTotalCommitCount, 56 | scrubCircularReferences: ReactPP.scrubCircularReferences, 57 | }; 58 | -------------------------------------------------------------------------------- /src/schemas/node-schema.ts: -------------------------------------------------------------------------------- 1 | interface Node { 2 | type: string; 3 | actualDuration: number; 4 | } 5 | 6 | class NodeSchema { 7 | name: string; 8 | renderTime: number; 9 | 10 | constructor(node: Node) { 11 | this.name = node.type; 12 | this.renderTime = node.actualDuration; 13 | } 14 | } 15 | 16 | export default NodeSchema; 17 | -------------------------------------------------------------------------------- /src/utils/Observable.ts: -------------------------------------------------------------------------------- 1 | type CallBackFunc = (data: any) => any; 2 | 3 | class Observable { 4 | observers: CallBackFunc[]; 5 | constructor() { 6 | this.observers = []; 7 | } 8 | 9 | subscribe(f: CallBackFunc) { 10 | this.observers.push(f); 11 | } 12 | 13 | unsubsribe(f: CallBackFunc) { 14 | this.observers = this.observers.filter(sub => sub !== f); 15 | } 16 | 17 | notify(data) { 18 | this.observers.forEach(observer => observer(data)); 19 | } 20 | } 21 | 22 | export default Observable; 23 | -------------------------------------------------------------------------------- /src/utils/Tree.ts: -------------------------------------------------------------------------------- 1 | import TreeNode from './TreeNode'; 2 | 3 | let fiberMap = undefined; 4 | let processedFibers = undefined; 5 | 6 | class Tree { 7 | uniqueId: number; 8 | componentList: TreeNode[]; 9 | effectList: any[]; // stretch feature 10 | root: TreeNode; 11 | fiberMap: Map; // a singleton reference 12 | processedFibers: WeakMap; // a singleton reference 13 | // uniqueId is used to identify a fiber to then help with counting re-renders 14 | // componentList 15 | constructor(rootNode, FiberMap, ProcessedFibers) { 16 | fiberMap = FiberMap; 17 | processedFibers = ProcessedFibers; 18 | this.uniqueId = fiberMap.size; 19 | this.componentList = []; 20 | this.effectList = []; 21 | this.root = null; 22 | this.processNode(rootNode, null); 23 | } 24 | 25 | processNode(fiberNode, previousTreeNode) { 26 | // id used to reference a fiber in fiberMap 27 | let id = undefined; 28 | // using a unique part of each fiber to identify it. 29 | // both current and alternate only 1 reference to this unique part 30 | // which we can use to uniquely identify a fiber node even in the case 31 | // of current and alternate switching per commit/ 32 | let uniquePart = undefined; 33 | 34 | // skipping special nodes like svg, path 35 | if (fiberNode.tag === 5) { 36 | if (fiberNode.elementType === 'svg' || fiberNode.elementType === 'path') return; 37 | } 38 | 39 | // unique part of a fiber node depends on its type. 40 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 41 | uniquePart = fiberNode.memoizedProps; 42 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 43 | uniquePart = fiberNode.elementType; 44 | } else if (fiberNode.tag === 3) { 45 | uniquePart = fiberNode.stateNode; 46 | } else if (fiberNode.tag === 7) { 47 | uniquePart = fiberNode; 48 | } else if (fiberNode.tag === 8) { 49 | uniquePart = fiberNode.memoizedProps; 50 | } else { 51 | uniquePart = fiberNode.stateNode; 52 | } 53 | 54 | // if this is a unique fiber (that both "current" and "alternate" fiber represents) 55 | // then add to the processedFiber to make sure we don't re-account this fiber. 56 | if (!processedFibers.has(uniquePart)) { 57 | id = this.uniqueId; 58 | this.uniqueId++; 59 | fiberMap.set(id, fiberNode); 60 | processedFibers.set(uniquePart, id); 61 | } else { 62 | id = processedFibers.get(uniquePart); 63 | } 64 | 65 | // If it's a HostRoot with a tag of 3 66 | // create a new TreeNode 67 | if (fiberNode.tag === 3 && !this.root) { 68 | this.root = new TreeNode(fiberNode, id); 69 | this.componentList.push(this.root); // push a copy 70 | 71 | if (fiberNode.child) { 72 | this.processNode(fiberNode.child, this.root); 73 | } 74 | } else { 75 | const newNode = new TreeNode(fiberNode, id); 76 | newNode.addParent(previousTreeNode); 77 | previousTreeNode.children.push(newNode); 78 | previousTreeNode.addChild(newNode); 79 | this.componentList.push(newNode); 80 | 81 | if (fiberNode.child) { 82 | this.processNode(fiberNode.child, newNode); 83 | } 84 | if (fiberNode.sibling) { 85 | this.processSiblingNode(fiberNode.sibling, newNode, previousTreeNode); 86 | } 87 | } 88 | } 89 | 90 | processSiblingNode(fiberNode, previousTreeNode, parentTreeNode) { 91 | let uniquePart = undefined; 92 | let id = undefined; 93 | if (fiberNode.tag === 0 || fiberNode.tag === 9) { 94 | uniquePart = fiberNode.memoizedProps; 95 | } else if (fiberNode.tag === 10 || fiberNode.tag === 11 || fiberNode.tag === 9 || fiberNode.tag === 15 || fiberNode.tag === 16) { 96 | uniquePart = fiberNode.elementType; 97 | } else if (fiberNode.tag === 3) { 98 | uniquePart = fiberNode.stateNode; 99 | } else if (fiberNode.tag === 7) { 100 | uniquePart = fiberNode; 101 | } else if (fiberNode.tag === 8) { 102 | uniquePart = fiberNode.memoizedProps; 103 | } else { 104 | uniquePart = fiberNode.stateNode; 105 | } 106 | if (!processedFibers.has(uniquePart)) { 107 | id = this.uniqueId; 108 | this.uniqueId++; 109 | fiberMap.set(id, fiberNode); 110 | 111 | processedFibers.set(uniquePart, id); 112 | } else { 113 | id = processedFibers.get(uniquePart); 114 | } 115 | 116 | const newNode = new TreeNode(fiberNode, id); 117 | newNode.addParent(parentTreeNode); 118 | parentTreeNode.children.push(newNode); 119 | previousTreeNode.addSibling(newNode); 120 | this.componentList.push(newNode); 121 | 122 | if (fiberNode.child) { 123 | this.processNode(fiberNode.child, newNode); 124 | } 125 | if (fiberNode.sibling) { 126 | this.processSiblingNode(fiberNode.sibling, newNode, parentTreeNode); 127 | } 128 | } 129 | 130 | getCommitssOfComponent(name, serialize = false) { 131 | let componentList = []; 132 | 133 | componentList = this.componentList.filter(item => item.fiberName === name); 134 | 135 | if (serialize) { 136 | componentList = componentList.map(item => item.toSerializable()); 137 | } 138 | return { 139 | ...this, 140 | componentList, 141 | }; 142 | } 143 | 144 | // stretch feature, possible todo but needs extensive testing. 145 | setStateOfRender() { 146 | this.componentList.forEach(component => { 147 | if (component.tag === 1 && component.memoizedState) { 148 | console.log(component.stateNode); 149 | component.stateNode.setState({...component.memoizedState}); 150 | } 151 | }); 152 | } 153 | } 154 | 155 | export default Tree; 156 | -------------------------------------------------------------------------------- /src/utils/TreeNode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | /* eslint-disable no-case-declarations */ 3 | 4 | class TreeNode { 5 | // will add type after ;D 6 | uID: any; 7 | elementType: any; 8 | selfBaseDuration: any; 9 | memoizedProps: any; 10 | memoizedState: any; 11 | effectTag: any; 12 | ref: any; 13 | fiberName: any; 14 | updateQueue: any; 15 | tag: any; 16 | updateList: any[]; 17 | stateNode: { 18 | state: any; 19 | updater: any; 20 | tag: number; 21 | setState: any; 22 | }; 23 | parent: any; 24 | child: any; 25 | sibling: any; 26 | children: any; 27 | type: any; 28 | 29 | constructor(fiberNode, uID) { 30 | this.uID = uID; 31 | const {elementType, selfBaseDuration, memoizedState, memoizedProps, effectTag, tag, ref, updateQueue, stateNode, type} = fiberNode; 32 | this.elementType = elementType; 33 | this.selfBaseDuration = selfBaseDuration; 34 | this.memoizedProps = memoizedProps; 35 | this.memoizedState = memoizedState; 36 | this.effectTag = effectTag; 37 | this.ref = ref; 38 | this.type = type; 39 | this.fiberName = getElementName(fiberNode); 40 | this.updateQueue = updateQueue; // seems to be replaced entirely and since it exists directly under a fiber node, it can't be modified. 41 | this.tag = tag; 42 | this.updateList = []; 43 | this.children = []; 44 | this.parent = null; 45 | this.stateNode = stateNode; 46 | 47 | if (tag === 0 && !stateNode && memoizedState) { 48 | // pass in "queue" obj to spy on dispatch func 49 | console.log('attaching a spy', memoizedState.queue); 50 | 51 | const cb = (...args) => { 52 | this.updateList.push([...args]); 53 | }; 54 | } 55 | } 56 | 57 | addChild(treeNode): void { 58 | // remove other uneccessary properties 59 | this.child = treeNode; 60 | } 61 | 62 | addSibling(treeNode): void { 63 | // if (!node) return; 64 | this.sibling = treeNode; 65 | } 66 | 67 | addParent(treeNode): void { 68 | // if (!node) return; 69 | this.parent = treeNode; 70 | } 71 | 72 | toSerializable(): any { 73 | const newObj = {}; 74 | const omitList = [ 75 | 'memoizedProps', // currently working on serialization for this 76 | 'memoizedState', // and this as well 77 | 'updateList', 78 | 'updateQueue', 79 | 'ref', 80 | 'elementType', 81 | 'stateNode', // serialization needed for this? 82 | 'sibling', // maybe not needed 83 | 'type', // some circular references in here that we haven't accounted for 84 | ]; 85 | // transform each nested node to just ids where appropriate 86 | for (const key of Object.getOwnPropertyNames(this)) { 87 | if (omitList.indexOf(key) < 0) { 88 | switch (key) { 89 | case 'parent': 90 | newObj['parent_component_id'] = this[key] ? this[key].uID : this[key]; 91 | break; 92 | case 'fiberName': 93 | newObj['component_name'] = this[key]; 94 | break; 95 | case 'sibling': 96 | newObj['sibling_component_id'] = this[key].uID; 97 | break; 98 | case 'selfBaseDuration': 99 | newObj['self_base_duration'] = this[key]; 100 | break; 101 | case 'child': // probably not needed anymore, this prop seems to be redundant 102 | // newObj[`${key}ID`] = this[key].uID; 103 | break; 104 | case 'children': 105 | newObj[`children_ids`] = this[key].map(treeNode => treeNode.uID); 106 | break; 107 | case 'memoizedState': 108 | newObj['component_state'] = this[key]; 109 | break; 110 | case 'memoizedProps': 111 | if (this[key]) { 112 | newObj['component_props'] = this[key].hasOwnProperty('children') ? serializeMemoizedProps(this[key]) : this[key]; 113 | } else { 114 | newObj['component_props'] = this[key]; 115 | } 116 | // newObj["component_props"] = this[key]; 117 | break; 118 | case 'uID': 119 | newObj['component_id'] = this[key]; 120 | break; 121 | case 'stateNode': 122 | let value = null; 123 | if (this[key]) { 124 | if (this[key].tag === 5) { 125 | value = 'host component'; 126 | } else if (this[key].tag === 3) { 127 | value = 'host root'; 128 | } else { 129 | value = 'other type'; 130 | } 131 | } 132 | newObj['state_node'] = value; 133 | break; 134 | default: 135 | newObj[key] = this[key]; 136 | } 137 | } 138 | } 139 | 140 | return newObj; 141 | } 142 | } 143 | 144 | function getElementName(fiber) { 145 | let name = ''; 146 | switch (fiber.tag) { 147 | case 0: 148 | case 1: 149 | name = fiber.elementType.name || fiber.type.name; // somehow react lazy has a tag of 1, it seems to be grouped with the 150 | return name; 151 | case 3: 152 | name = 'Host Root'; 153 | return name; 154 | case 5: 155 | name = fiber.elementType; 156 | if (fiber.elementType.className) { 157 | name += `.${fiber.elementType.className}`; 158 | } else if (fiber.memoizedProps) { 159 | if (fiber.memoizedProps.className) { 160 | name += `.${fiber.memoizedProps.className}`; 161 | } else if (fiber.memoizedProps.id) { 162 | name += `.${fiber.memoizedProps.id}`; 163 | } 164 | } 165 | if (name.length > 10) { 166 | // truncate long name 167 | name = name.slice(0, 10); 168 | name += '...'; 169 | } 170 | return name; 171 | case 6: 172 | let textValue = fiber.stateNode.nodeValue; 173 | // just to help truncating long text value, exceeding 4 chars will get truncated. 174 | if (textValue.length > 10) { 175 | textValue = textValue.slice(0, 10); 176 | textValue = textValue.padEnd(3, '.'); 177 | } 178 | name = `text: ${textValue}`; 179 | return name; 180 | case 8: 181 | name = 'Strict Mode Zone'; 182 | return name; 183 | case 9: 184 | name = fiber.elementType._context.displayName; 185 | return name; 186 | case 10: 187 | name = 'Context'; // 188 | return name; 189 | case 11: 190 | name = fiber.elementType.displayName + '-' + `to:"${fiber.memoizedProps.to}"`; 191 | return name; 192 | case 13: 193 | name = 'Suspense Zone'; 194 | return name; 195 | case 16: 196 | name = 'Lazy - ' + fiber.elementType._result.name; 197 | return name; 198 | default: 199 | return `${typeof fiber.elementType !== 'string' ? fiber.elementType : 'unknown'}`; 200 | } 201 | } 202 | 203 | function serializeMemoizedProps(obj): any { 204 | if (!obj) return null; 205 | 206 | // list of props to omit from the resulting object in return statement 207 | const omitList = ['props', '_owner', '_store', '_sef', '_source', '_self']; 208 | 209 | let newObj = null; 210 | // loop through each prop to check if they exist on omitList 211 | // if yes then skip, no then include in the object being returned; 212 | if (Array.isArray(obj)) { 213 | if (!newObj) newObj = []; 214 | for (let i = 0; i < obj.length; i++) { 215 | const nestedChild = {}; 216 | for (const key of Object.getOwnPropertyNames(obj[i])) { 217 | if (omitList.indexOf(key) < 0) { 218 | nestedChild[key] = obj[i][key]; 219 | } 220 | } 221 | newObj.push(nestedChild); 222 | } 223 | } else { 224 | for (const key of Object.getOwnPropertyNames(obj)) { 225 | if (omitList.indexOf(key) < 0) { 226 | if (!newObj) newObj = {}; 227 | if (typeof obj[key] === 'object') { 228 | newObj[key] = serializeMemoizedProps(obj[key]); 229 | } else if (typeof obj[key] === 'symbol') { 230 | newObj[key] = obj[key].toString(); 231 | } else { 232 | newObj[key] = obj[key]; 233 | } 234 | } 235 | } 236 | } 237 | 238 | return newObj; 239 | } 240 | 241 | export default TreeNode; 242 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | /* eslint-disable no-empty */ 3 | import Tree from './Tree'; 4 | import Observable from './Observable'; 5 | 6 | let changes = []; 7 | const processedFibers = new WeakMap(); 8 | const fiberMap = new Map(); 9 | 10 | function mountToReactRoot(reactRoot, projectID?: string | null) { 11 | // Reset changes 12 | changes = []; 13 | const changeObservable = new Observable(); 14 | 15 | function getSet(obj, propName) { 16 | const newPropName = `_${propName}`; 17 | obj[newPropName] = obj[propName]; 18 | Object.defineProperty(obj, propName, { 19 | get() { 20 | return this[newPropName]; 21 | }, 22 | set(newVal) { 23 | this[newPropName] = newVal; 24 | changes.push(new Tree(this[newPropName], fiberMap, processedFibers)); 25 | changeObservable.notify(changes); 26 | if (projectID) sendData(changes, projectID); 27 | }, 28 | }); 29 | } 30 | 31 | // Lift parent of react fibers tree 32 | const parent = reactRoot._reactRootContainer._internalRoot; 33 | changes.push(new Tree(parent.current, fiberMap, processedFibers)); 34 | if (projectID) sendData(changes, projectID); 35 | // Add listener to react fibers tree so changes can be recorded 36 | getSet(parent, 'current'); 37 | return { 38 | changes, 39 | changeObserver: changeObservable, 40 | }; 41 | } 42 | 43 | function scrubCircularReferences(changes) { 44 | // loop through the different commits 45 | // for every commit check the componentList 46 | // scrub the circular references and leave the flat one there 47 | 48 | const scrubChanges = changes.map(commit => { 49 | return commit.componentList.map(component => { 50 | return component.toSerializable(); 51 | }); 52 | }); 53 | return scrubChanges; 54 | } 55 | 56 | function removeCircularRefs(changes) { 57 | // loop through the different commits 58 | // for every commit check the componentList 59 | // scrub the circular references and leave the flat one there 60 | 61 | const scrubChanges = changes.map(commit => { 62 | return commit.componentList.map(component => { 63 | return component.toSerializable(); 64 | }); 65 | }); 66 | return scrubChanges; 67 | } 68 | 69 | /** 70 | * 71 | * @param {number} threshold The rendering time to filter for. 72 | */ 73 | function getAllSlowComponentRenders(changes, threshold) { 74 | // referencing "changes" in the global scope 75 | const scrubChanges = removeCircularRefs(changes); 76 | 77 | // rework this so that we can use the pre-serialized data 78 | const result = scrubChanges.map(commit => { 79 | return commit.filter(component => component.self_base_duration >= threshold); 80 | }); 81 | 82 | return result; 83 | } 84 | 85 | // function checkTime(fiber, threshold) { 86 | // return fiber.selfBaseDuration > threshold; 87 | // } 88 | 89 | // function getComponentRenderTime(componentName) { 90 | // console.log("changes", changes); 91 | // console.log("component name", componentName); 92 | 93 | // return "what"; 94 | // } 95 | 96 | function getTotalCommitCount(changes, name?: string, storageType?: string) { 97 | const componentStore = new Map(); 98 | 99 | let filteredChanges = []; 100 | if (name) { 101 | filteredChanges = changes.map(commit => { 102 | return commit.getCommitssOfComponent(name); 103 | }); 104 | } else { 105 | filteredChanges = changes; 106 | } 107 | 108 | // looping through each commit's componentList to tally up the renderCount 109 | filteredChanges.forEach((commit, commitIndex) => { 110 | commit.componentList.forEach((component, componentIndex) => { 111 | if (!componentStore.has(component.uID)) { 112 | componentStore.set(component.uID, {component, renderCount: 1}); 113 | } else { 114 | if (didFiberRender(changes[commitIndex ? commitIndex - 1 : 0].componentList[componentIndex], component)) { 115 | componentStore.get(component.uID).renderCount += 1; 116 | } 117 | } 118 | }); 119 | }); 120 | 121 | let result: Map | any[] = []; 122 | if (storageType && storageType === 'array') { 123 | result = Array.from(componentStore.values()); 124 | } else { 125 | result = componentStore; 126 | } 127 | 128 | return result; 129 | } 130 | 131 | function didFiberRender(prevFiber, nextFiber) { 132 | switch (nextFiber.tag) { 133 | case 0: 134 | case 1: 135 | case 3: 136 | // case 5: 137 | return (nextFiber.effectTag & 1) === 1; 138 | default: 139 | return ( 140 | prevFiber.memoizedProps !== nextFiber.memoizedProps || 141 | prevFiber.memoizedState !== nextFiber.memoizedState || 142 | prevFiber.ref !== nextFiber.ref 143 | ); 144 | } 145 | } 146 | 147 | function didHooksChange(previous, next) { 148 | if (previous == null || next == null) { 149 | return false; 150 | } 151 | if ( 152 | next.hasOwnProperty('baseState') && 153 | next.hasOwnProperty('memoizedState') && 154 | next.hasOwnProperty('next') && 155 | next.hasOwnProperty('queue') 156 | ) { 157 | if (next.memoizedState !== previous.memoizedState) { 158 | return true; 159 | } else { 160 | } 161 | } 162 | return false; 163 | } 164 | 165 | function getChangedKeys(previous, next) { 166 | if (previous == null || next == null) { 167 | return null; 168 | } 169 | // We can't report anything meaningful for hooks changes. 170 | if ( 171 | next.hasOwnProperty('baseState') && 172 | next.hasOwnProperty('memoizedState') && 173 | next.hasOwnProperty('next') && 174 | next.hasOwnProperty('queue') 175 | ) { 176 | return null; 177 | } 178 | 179 | const keys = new Set([...Object.keys(previous), ...Object.keys(next)]); 180 | const changedKeys = []; 181 | // for (const key of keys) { 182 | // if (previous[key] !== next[key]) { 183 | // changedKeys.push(key); 184 | // } 185 | // } 186 | keys.forEach(key => { 187 | if (previous[key] !== next[key]) { 188 | changedKeys.push(key); 189 | } 190 | }); 191 | 192 | return changedKeys; 193 | } 194 | 195 | async function sendData(changes, projectID) { 196 | if (!projectID) return; 197 | 198 | const data = scrubCircularReferences(changes); 199 | 200 | console.log(data); 201 | const request = await fetch(`https://react-pinpoint-api.herokuapp.com/api/commit/${projectID}`, { 202 | method: 'POST', 203 | mode: 'cors', 204 | credentials: 'include', 205 | headers: { 206 | 'Content-Type': 'application/json', 207 | }, 208 | body: JSON.stringify({ 209 | changes: data[data.length - 1], 210 | }), 211 | }); 212 | 213 | const response = await request.json(); 214 | console.log('response from server', response); 215 | } 216 | 217 | export default {mountToReactRoot, getAllSlowComponentRenders, getTotalCommitCount, scrubCircularReferences}; 218 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": "./src", 5 | "lib": ["dom", "ES2020"] 6 | }, 7 | "exclude": ["lib/**/*"] 8 | } 9 | --------------------------------------------------------------------------------