├── .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 | [](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 |
--------------------------------------------------------------------------------