├── .eslintrc
├── .github
└── workflows
│ ├── build.yml
│ └── gh-pages.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── dist
├── .gitignore
└── .npmignore
├── examples
├── demo-app
│ ├── package-lock.json
│ ├── package.json
│ ├── server.js
│ └── src
│ │ ├── index.html
│ │ └── index.jsx
├── next
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── pages
│ │ ├── _app.tsx
│ │ └── _document.tsx
│ └── tsconfig.json
├── playwright
│ ├── package-lock.json
│ ├── package.json
│ └── script.mjs
└── puppeteer
│ ├── package-lock.json
│ ├── package.json
│ └── script.js
├── jest.config.js
├── package-lock.json
├── package.json
├── playground
├── cases
│ ├── app.tsx
│ ├── bailouts.tsx
│ ├── basic-nested-set-state.tsx
│ ├── basic-parent-element-change.tsx
│ ├── class-component.tsx
│ ├── complex-composition-on-one-component.tsx
│ ├── context.tsx
│ ├── hooks.tsx
│ ├── index.ts
│ ├── mount-unmount.tsx
│ ├── props-changes.tsx
│ ├── screenshot-demo.tsx
│ ├── set-state-by-event-handler.tsx
│ ├── suspense.tsx
│ └── use-effects.tsx
├── create-test-case-wrapper.ts
├── data-client-example.js
├── dom-utils.ts
├── index.css
├── index.html
├── index.tsx
├── react-dom.tsx
├── react.tsx
└── types.ts
├── scripts
├── build-gh-pages.js
├── build.js
└── server.js
├── src
├── common
│ ├── constants.ts
│ ├── consumer-types.ts
│ ├── rempl.d.ts
│ └── types.d.ts
├── data-client
│ ├── headless-browser.ts
│ └── index.ts
├── data
│ ├── fiber-dataset.ts
│ ├── index.ts
│ ├── process-events.ts
│ ├── subscription.ts
│ ├── tree.test.ts
│ └── tree.ts
├── publisher
│ ├── config.ts
│ ├── index.ts
│ ├── overlay.ts
│ ├── react-devtools-hook.ts
│ ├── react-integration
│ │ ├── core.ts
│ │ ├── devtools-hook-handlers.ts
│ │ ├── dispatcher-trap.ts
│ │ ├── highlight-api.ts
│ │ ├── index.ts
│ │ ├── interaction-api.ts
│ │ ├── unmounted-fiber-leak-detector.ts
│ │ └── utils
│ │ │ ├── arrayDiff.ts
│ │ │ ├── constants.ts
│ │ │ ├── getDisplayName.ts
│ │ │ ├── getDisplayNameFromJsx.ts
│ │ │ ├── getFiberFlags.ts
│ │ │ ├── getInternalReactConstants.ts
│ │ │ ├── isPlainObject.ts
│ │ │ ├── objectDiff.ts
│ │ │ ├── separateDisplayNameAndHOCs.ts
│ │ │ ├── simpleValueSerialization.ts
│ │ │ └── stackTrace.ts
│ ├── rempl-publisher.ts
│ ├── types.ts
│ └── utils
│ │ ├── renderer-info.ts
│ │ └── resolveSourceLoc.ts
└── ui
│ ├── App.css
│ ├── App.tsx
│ ├── components
│ ├── appbar
│ │ ├── AppBar.css
│ │ ├── AppBar.tsx
│ │ ├── Renderer.css
│ │ └── Renderer.tsx
│ ├── common
│ │ ├── ButtonToggle.css
│ │ ├── ButtonToggle.tsx
│ │ ├── FiberHocNames.css
│ │ ├── FiberHocNames.tsx
│ │ ├── FiberId.css
│ │ ├── FiberId.tsx
│ │ ├── FiberKey.css
│ │ ├── FiberKey.tsx
│ │ ├── FiberMaybeLeak.css
│ │ ├── FiberMaybeLeak.tsx
│ │ ├── SourceLoc.css
│ │ ├── SourceLoc.tsx
│ │ └── icons.tsx
│ ├── details
│ │ ├── CallStack.css
│ │ ├── CallStack.tsx
│ │ ├── Details.css
│ │ ├── Details.tsx
│ │ ├── EventChangesSummary.css
│ │ ├── EventChangesSummary.tsx
│ │ ├── Fiber.css
│ │ ├── Fiber.tsx
│ │ ├── FiberHeader.css
│ │ ├── FiberHeader.tsx
│ │ ├── FiberLink.css
│ │ ├── FiberLink.tsx
│ │ ├── diff
│ │ │ ├── Diff.css
│ │ │ ├── Diff.tsx
│ │ │ ├── DiffArray.tsx
│ │ │ ├── DiffObject.tsx
│ │ │ ├── DiffSimple.tsx
│ │ │ ├── ShallowEqual.css
│ │ │ └── ShallowEqual.tsx
│ │ ├── event-list
│ │ │ ├── EventList.css
│ │ │ ├── EventList.tsx
│ │ │ ├── EventListCommitEvent.css
│ │ │ ├── EventListCommitEvent.tsx
│ │ │ ├── EventListEntry.css
│ │ │ ├── EventListEntry.tsx
│ │ │ ├── EventListFiberEvent.css
│ │ │ ├── EventListFiberEvent.tsx
│ │ │ ├── EventRenderReasons.css
│ │ │ ├── EventRenderReasons.tsx
│ │ │ ├── EventRenderReasonsItem.css
│ │ │ └── EventRenderReasonsItem.tsx
│ │ └── info
│ │ │ ├── ChangesMatrix.css
│ │ │ ├── ChangesMatrix.tsx
│ │ │ ├── FiberInfo.css
│ │ │ ├── FiberInfo.tsx
│ │ │ ├── FiberInfoSection.css
│ │ │ ├── FiberInfoSection.tsx
│ │ │ ├── FiberInfoSectionAncestors.css
│ │ │ ├── FiberInfoSectionAncestors.tsx
│ │ │ ├── FiberInfoSectionConsumers.css
│ │ │ ├── FiberInfoSectionConsumers.tsx
│ │ │ ├── FiberInfoSectionContexts.css
│ │ │ ├── FiberInfoSectionContexts.tsx
│ │ │ ├── FiberInfoSectionEvents.css
│ │ │ ├── FiberInfoSectionEvents.tsx
│ │ │ ├── FiberInfoSectionHooks.css
│ │ │ ├── FiberInfoSectionHooks.tsx
│ │ │ ├── FiberInfoSectionLeakedHooks.tsx
│ │ │ ├── FiberInfoSectionMemoHooks.css
│ │ │ ├── FiberInfoSectionMemoHooks.tsx
│ │ │ ├── FiberInfoSectionProps.css
│ │ │ └── FiberInfoSectionProps.tsx
│ ├── fiber-tree
│ │ ├── ButtonExpand.css
│ │ ├── ButtonExpand.svg
│ │ ├── ButtonExpand.tsx
│ │ ├── ScrollSelectedIntoViewIfNeeded.tsx
│ │ ├── Tree.css
│ │ ├── Tree.tsx
│ │ ├── TreeHeader.css
│ │ ├── TreeHeader.tsx
│ │ ├── TreeLeaf.css
│ │ ├── TreeLeaf.tsx
│ │ ├── TreeLeafCaption.css
│ │ ├── TreeLeafCaption.tsx
│ │ ├── TreeLeafCaptionContent.css
│ │ ├── TreeLeafCaptionContent.tsx
│ │ ├── TreeLeafTimings.css
│ │ ├── TreeLeafTimings.tsx
│ │ └── contexts.ts
│ ├── misc
│ │ ├── FiberTreeKeyboardNav.tsx
│ │ ├── WaitingForReady.css
│ │ ├── WaitingForReady.tsx
│ │ ├── WaitingForRenderer.css
│ │ └── WaitingForRenderer.tsx
│ ├── statebar
│ │ ├── StateBar.css
│ │ └── StateBar.tsx
│ ├── statusbar
│ │ ├── StatusBar.css
│ │ └── StatusBar.tsx
│ └── toolbar
│ │ ├── ComponentSearch.css
│ │ ├── ComponentSearch.tsx
│ │ ├── SearchMatchesNav.css
│ │ ├── SearchMatchesNav.tsx
│ │ ├── SelectionHistoryNavigation.css
│ │ ├── SelectionHistoryNavigation.tsx
│ │ ├── Toolbar.css
│ │ └── Toolbar.tsx
│ ├── images
│ ├── dots.svg
│ ├── event-effect-create.svg
│ ├── event-effect-destroy.svg
│ ├── event-mount.svg
│ ├── event-unmount.svg
│ ├── event-update-bailout-memo.svg
│ ├── event-update-bailout-state.svg
│ ├── event-update.svg
│ ├── expander.svg
│ ├── expose-to-global.svg
│ ├── fiber-key-tail.svg
│ ├── pick-component.svg
│ ├── pin.svg
│ ├── source-loc-open.svg
│ ├── source-loc.svg
│ ├── table-sort-asc.svg
│ ├── table-sort-desc.svg
│ ├── table-sortable.svg
│ ├── update-trigger.svg
│ ├── warning-exc-sign.svg
│ └── warning.svg
│ ├── index.css
│ ├── index.tsx
│ ├── pages
│ ├── Commits.css
│ ├── Commits.tsx
│ ├── Components.css
│ ├── Components.tsx
│ ├── ComponentsTree.css
│ ├── ComponentsTree.tsx
│ ├── MaybeLeaks.css
│ ├── MaybeLeaks.tsx
│ ├── components
│ │ ├── ComponentSearch.css
│ │ ├── ComponentSearch.tsx
│ │ ├── ComponentsTable.css
│ │ ├── ComponentsTable.tsx
│ │ ├── Toolbar.css
│ │ └── Toolbar.tsx
│ ├── index.tsx
│ └── maybe-leaks
│ │ ├── Fiber.css
│ │ ├── Fiber.tsx
│ │ ├── FiberGroup.css
│ │ ├── FiberGroup.tsx
│ │ ├── LeaksList.css
│ │ ├── LeaksList.tsx
│ │ ├── Toolbar.css
│ │ └── Toolbar.tsx
│ ├── rempl-subscriber.ts
│ ├── types.ts
│ └── utils
│ ├── duration.ts
│ ├── events.tsx
│ ├── fiber-maps.tsx
│ ├── fiber.ts
│ ├── find-match.tsx
│ ├── highlighting.tsx
│ ├── layout.ts
│ ├── memory-leaks.tsx
│ ├── open-file.tsx
│ ├── page.tsx
│ ├── pinned.tsx
│ ├── react-renderers.tsx
│ ├── selection.tsx
│ ├── source-locations.tsx
│ ├── subscription.ts
│ └── tree.ts
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "node": true,
6 | "es6": true
7 | },
8 | "parserOptions": {
9 | "ecmaVersion": 2020,
10 | "sourceType": "module",
11 | "ecmaFeatures": {
12 | "jsx": true // Allows for the parsing of JSX
13 | }
14 | },
15 | "settings": {
16 | "react": {
17 | "version": "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
18 | }
19 | },
20 | "parser": "@typescript-eslint/parser",
21 | "plugins": ["@typescript-eslint"],
22 | "extends": [
23 | "plugin:react/recommended",
24 | "plugin:@typescript-eslint/eslint-recommended",
25 | "plugin:@typescript-eslint/recommended"
26 | ],
27 | "ignorePatterns": ["dist", "scripts"],
28 | "rules": {
29 | "react/prop-types": "off",
30 | "no-duplicate-case": 2,
31 | "@typescript-eslint/ban-ts-comment": "off",
32 | "@typescript-eslint/explicit-module-boundary-types": "off",
33 | "@typescript-eslint/no-explicit-any": "off",
34 | "@typescript-eslint/no-this-alias": "off",
35 | "@typescript-eslint/no-var-requires": "off",
36 | "@typescript-eslint/no-unused-vars": [
37 | 2,
38 | {
39 | "vars": "all",
40 | "args": "after-used"
41 | }
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | lint-test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Setup Node.js
13 | uses: actions/setup-node@v2
14 | with:
15 | node-version: 18
16 | cache: "npm"
17 | - run: npm ci
18 | - run: npm run lint
19 | - run: npm run tscheck
20 | - run: npm test
21 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GH-pages
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
9 | jobs:
10 | # This workflow contains a single job called "build"
11 | build:
12 | # The type of runner that the job will run on
13 | runs-on: ubuntu-latest
14 |
15 | # Steps represent a sequence of tasks that will be executed as part of the job
16 | steps:
17 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
18 | - uses: actions/checkout@v2
19 | - name: Setup Node.js
20 | uses: actions/setup-node@v2
21 | with:
22 | node-version: 18
23 | cache: "npm"
24 | - run: npm ci
25 | - run: npm run build-gh-pages
26 | - name: Deploy
27 | uses: peaceiris/actions-gh-pages@v3
28 | with:
29 | github_token: ${{ secrets.GITHUB_TOKEN }}
30 | publish_dir: ./.gh-pages
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .idea
3 | .gh-pages
4 | .next
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.html
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "endOfLine": "auto"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=827846
3 | // for the documentation about the extensions.json format
4 | "recommendations": [
5 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
6 |
7 | "esbenp.prettier-vscode", // Prettier
8 | "dbaeumer.vscode-eslint" // ESLint
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.tabSize": 2,
4 | "editor.defaultFormatter": "esbenp.prettier-vscode",
5 | "[typescript]": {
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "[typescriptreact]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/dist/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 | !.npmignore
--------------------------------------------------------------------------------
/dist/.npmignore:
--------------------------------------------------------------------------------
1 | *
2 | !data-client.js
3 | !headless-browser-client.js
4 | !headless-browser-client.mjs
5 | !react-render-tracker.js
6 |
--------------------------------------------------------------------------------
/examples/demo-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-render-tracker-demo-app",
3 | "version": "1.0.0",
4 | "description": "Demo app for React Render Tracker examples",
5 | "private": true,
6 | "main": "src/index.jsx",
7 | "scripts": {
8 | "start": "node server"
9 | },
10 | "dependencies": {
11 | "esbuild": "^0.14.38",
12 | "express": "^4.18.1",
13 | "react": "^18.1.0",
14 | "react-dom": "^18.1.0"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/examples/demo-app/server.js:
--------------------------------------------------------------------------------
1 | const esbuild = require("esbuild");
2 | const express = require("express");
3 | const server = express();
4 |
5 | function asyncResponse(asyncFn, contentType = "text/javascript") {
6 | return async (req, res) => {
7 | // console.log("request", req.url);
8 | try {
9 | const content = await asyncFn();
10 |
11 | res.type(contentType);
12 | res.send(content);
13 | res.end();
14 | } catch (e) {
15 | res.status(500);
16 | res.end(e.message);
17 | }
18 | };
19 | }
20 |
21 | exports.startAppServer = function (options) {
22 | options = options || {};
23 |
24 | const port = options.port || 0;
25 |
26 | server.use(express.static(__dirname + "/src"));
27 | for (let [url, generator] of Object.entries({
28 | "/app.js": async () => {
29 | const bundle = await esbuild.build({
30 | entryPoints: [__dirname + "/src/index.jsx"],
31 | bundle: true,
32 | // minify: true, // React Render Tracker currently doesn't support for a production version of React
33 | format: "esm",
34 | write: false,
35 | });
36 |
37 | return bundle.outputFiles[0].text;
38 | },
39 | })) {
40 | server.get(url, asyncResponse(generator));
41 | }
42 |
43 | return new Promise(resolve => {
44 | server.listen(port, async function () {
45 | const host = `http://localhost:${this.address().port}`;
46 |
47 | console.log(`Server listen on ${host}`);
48 | console.log();
49 |
50 | resolve({
51 | host,
52 | close: () => this.close(),
53 | });
54 | });
55 | });
56 | };
57 |
58 | if (require.main === module) {
59 | exports.startAppServer({ port: 3111 });
60 | }
61 |
--------------------------------------------------------------------------------
/examples/demo-app/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Demo app
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/demo-app/src/index.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ReactDOM from "react-dom/client";
3 |
4 | function App() {
5 | const [count, setCount] = React.useState(0);
6 |
7 | React.useEffect(() => {
8 | console.log("rendered");
9 | });
10 |
11 | return (
12 | <>
13 | Hello world
14 | {count}
15 |
18 | >
19 | );
20 | }
21 |
22 | const reactRoot = ReactDOM.createRoot(document.getElementById("app"));
23 |
24 | reactRoot.render();
25 |
--------------------------------------------------------------------------------
/examples/next/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/examples/next/next.config.js:
--------------------------------------------------------------------------------
1 | const CopyPlugin = require("copy-webpack-plugin");
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = {
5 | webpack: config => {
6 | // append the CopyPlugin to copy the file to your public dir
7 | config.plugins.push(
8 | new CopyPlugin({
9 | patterns: [
10 | {
11 | from: "node_modules/react-render-tracker/dist/react-render-tracker.js",
12 | to: "static",
13 | },
14 | ],
15 | })
16 | );
17 |
18 | // Important: return the modified config
19 | return config;
20 | },
21 | };
22 |
23 | module.exports = nextConfig;
24 |
--------------------------------------------------------------------------------
/examples/next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "workspace",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@types/node": "22.13.2",
13 | "@types/react": "18.2.25",
14 | "@types/react-dom": "18.2.10",
15 | "copy-webpack-plugin": "^12.0.2",
16 | "eslint": "9.20.1",
17 | "eslint-config-next": "15.1.7",
18 | "next": "15.1.7",
19 | "react": "18.2.0",
20 | "react-dom": "18.2.0",
21 | "react-render-tracker": "file:../..",
22 | "typescript": "5.7.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/examples/next/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | function Demo() {
4 | const [count, setCount] = React.useState(0);
5 |
6 | return (
7 |
8 | Counter is {count}
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
16 | export default function App() {
17 | return (
18 | <>
19 | My App
20 |
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/examples/next/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Head, Html, Main, NextScript } from "next/document";
3 |
4 | export default function Document() {
5 | return (
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/examples/next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/examples/playwright/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-render-tracker-playwright-example",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Example of using React Render Tracker with playwright",
6 | "main": "index.js",
7 | "scripts": {
8 | "start": "node script.mjs"
9 | },
10 | "dependencies": {
11 | "playwright": "^1.34.3",
12 | "react-render-tracker": "file:../..",
13 | "react-render-tracker-demo-app": "file:../_app"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/playwright/script.mjs:
--------------------------------------------------------------------------------
1 | const startTime = Date.now();
2 |
3 | import { chromium } from "playwright";
4 | import { startAppServer } from "../demo-app/server.js";
5 | import newTrackerClient from "react-render-tracker/headless-browser-client";
6 |
7 | async function runReactScenarioSet(run) {
8 | const [browser, server] = await Promise.all([
9 | chromium.launch({ headless: true }),
10 | startAppServer(),
11 | ]);
12 |
13 | await run(async function runReactScenario(url, scenario) {
14 | const page = await browser.newPage();
15 | const rrt = await newTrackerClient(page);
16 |
17 | await page.goto(new URL(url, server.host).href);
18 | await scenario({ browser, page, rrt });
19 | await page.close();
20 | });
21 |
22 | await browser.close();
23 | await server.close();
24 | }
25 |
26 | runReactScenarioSet(async runReactScenario => {
27 | await runReactScenario("/", async ({ page, rrt }) => {
28 | const eventCountBeforeAction = await rrt.getEventCount();
29 |
30 | // do some actions
31 | await page.click("button");
32 |
33 | // dump events after an action
34 | console.log(await rrt.getEvents(eventCountBeforeAction));
35 | });
36 |
37 | console.log("DONE in", Date.now() - startTime, "ms");
38 | });
39 |
--------------------------------------------------------------------------------
/examples/puppeteer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-render-tracker-puppeteer-example",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Example of using React Render Tracker with puppeteer",
6 | "main": "index.js",
7 | "scripts": {
8 | "start": "node script.js"
9 | },
10 | "dependencies": {
11 | "puppeteer": "^20.5.0",
12 | "react-render-tracker": "file:../..",
13 | "react-render-tracker-demo-app": "file:../_app"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/puppeteer/script.js:
--------------------------------------------------------------------------------
1 | const startTime = Date.now();
2 |
3 | const puppeteer = require("puppeteer");
4 | const { startAppServer } = require("../demo-app/server");
5 | const newTrackerClient = require("react-render-tracker/headless-browser-client");
6 |
7 | async function runReactScenarioSet(run) {
8 | const [browser, server] = await Promise.all([
9 | puppeteer.launch({ headless: "new" }),
10 | startAppServer(),
11 | ]);
12 |
13 | await run(async function runReactScenario(url, scenario) {
14 | const page = await browser.newPage();
15 | const rrt = await newTrackerClient(page);
16 |
17 | await page.goto(new URL(url, server.host).href);
18 | await scenario({ browser, page, rrt });
19 | await page.close();
20 | });
21 |
22 | await browser.close();
23 | await server.close();
24 | }
25 |
26 | runReactScenarioSet(async runReactScenario => {
27 | await runReactScenario("/", async ({ page, rrt }) => {
28 | const eventCountBeforeAction = await rrt.getEventCount();
29 |
30 | // do some actions
31 | await page.click("button");
32 |
33 | // dump events after an action
34 | console.log(await rrt.getEvents(eventCountBeforeAction));
35 | });
36 |
37 | console.log("DONE in", Date.now() - startTime, "ms");
38 | });
39 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-render-tracker",
3 | "version": "0.7.8",
4 | "description": "React render tracker – a tool to discover performance issues related to unintended re-renders",
5 | "repository": "lahmatiy/react-render-tracker",
6 | "license": "MIT",
7 | "keywords": [
8 | "react",
9 | "devtools",
10 | "development",
11 | "render",
12 | "tracker",
13 | "performance"
14 | ],
15 | "main": "./dist/react-render-tracker.js",
16 | "unpkg": "./dist/react-render-tracker.js",
17 | "jsdelivr": "./dist/react-render-tracker.js",
18 | "exports": {
19 | ".": "./dist/react-render-tracker.js",
20 | "./package.json": "./package.json",
21 | "./data-client": "./dist/data-client.js",
22 | "./headless-browser-client": {
23 | "import": "./dist/headless-browser-client.mjs",
24 | "require": "./dist/headless-browser-client.js"
25 | },
26 | "./dist/*": "./dist/*.js",
27 | "./dist/*.js": "./dist/*.js",
28 | "./dist/*.mjs": "./dist/*.mjs"
29 | },
30 | "scripts": {
31 | "test": "jest src",
32 | "lint": "eslint playground src",
33 | "tscheck": "tsc --noEmit",
34 | "start": "node scripts/server",
35 | "build": "node scripts/build",
36 | "build-gh-pages": "node scripts/build-gh-pages",
37 | "prepublishOnly": "npm run build"
38 | },
39 | "dependencies": {},
40 | "devDependencies": {
41 | "@discoveryjs/json-ext": "^0.5.7",
42 | "@types/express": "^4.17.17",
43 | "@types/jest": "^29.5.3",
44 | "@types/lodash.debounce": "^4.0.7",
45 | "@types/react": "^18.2.20",
46 | "@types/react-dom": "^18.2.7",
47 | "@types/semver": "^7.5.0",
48 | "@typescript-eslint/eslint-plugin": "^6.4.0",
49 | "@typescript-eslint/parser": "^6.4.0",
50 | "cors": "^2.8.5",
51 | "esbuild": "^0.19.2",
52 | "eslint": "^8.47.0",
53 | "eslint-plugin-react": "^7.33.2",
54 | "express": "^4.18.2",
55 | "express-open-in-editor": "^3.1.1",
56 | "jest": "^29.6.2",
57 | "lodash.debounce": "^4.0.8",
58 | "prettier": "^2.8.8",
59 | "react": "^18.2.0",
60 | "react-dom": "^18.2.0",
61 | "rempl": "1.0.0-alpha.23",
62 | "semver": "^7.5.4",
63 | "source-map-js": "^1.0.2",
64 | "ts-jest": "^29.1.1",
65 | "typescript": "^5.1.6"
66 | },
67 | "files": [
68 | "dist"
69 | ]
70 | }
71 |
--------------------------------------------------------------------------------
/playground/cases/app.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "App #1",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | const [name, setName] = React.useState("World");
11 |
12 | return (
13 |
14 | }
16 | button={
17 |
29 | );
30 | }
31 |
32 | function Toolbar({
33 | input,
34 | button,
35 | }: {
36 | input: React.ReactNode;
37 | button: React.ReactNode;
38 | }) {
39 | return (
40 |
41 | {input}
42 | {button}
43 |
44 | );
45 | }
46 |
47 | function Input({
48 | value,
49 | onInput,
50 | }: {
51 | value: string;
52 | onInput: (value: string) => void;
53 | }) {
54 | return (
55 | onInput((e.target as HTMLInputElement).value)}
58 | />
59 | );
60 | }
61 |
62 | function Button({
63 | caption,
64 | onClick,
65 | }: {
66 | caption: React.ReactNode;
67 | onClick: () => void;
68 | }) {
69 | return ;
70 | }
71 |
72 | const List = function List({ children }: { children?: React.ReactNode }) {
73 | return ;
74 | };
75 |
76 | function ListItem({ caption }: { caption: string }) {
77 | return {caption};
78 | }
79 |
--------------------------------------------------------------------------------
/playground/cases/basic-nested-set-state.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Basic nested render with setState() via useEffect()",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return ;
11 | }
12 |
13 | function Child() {
14 | const [mounted, setMounted] = React.useState("Fail: waiting for mount");
15 |
16 | React.useEffect(() => {
17 | setMounted("OK");
18 | }, []);
19 |
20 | return <>{mounted}>;
21 | }
22 |
--------------------------------------------------------------------------------
/playground/cases/basic-parent-element-change.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Basic render with changed parent element",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return ;
11 | }
12 |
13 | function ChildWrapper() {
14 | const [mounted, setMounted] = React.useState("Fail: waiting for mount");
15 |
16 | React.useEffect(() => {
17 | setMounted("OK");
18 | }, []);
19 |
20 | return (
21 |
22 | {mounted}
23 |
24 |
25 | );
26 | }
27 |
28 | function Child() {
29 | return <>child element>;
30 | }
31 |
--------------------------------------------------------------------------------
/playground/cases/complex-composition-on-one-component.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Complex composition on one component",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | K
19 |
20 | );
21 | }
22 |
23 | function Foo({ children }: { children?: React.ReactNode }) {
24 | return <>{children}>;
25 | }
26 |
27 | function Bar({ children }: { children?: React.ReactNode }) {
28 | return <>{children}>;
29 | }
30 |
31 | const BarMemo = React.memo(Baz);
32 |
33 | function Baz({ children }: { children?: React.ReactNode }) {
34 | return <>{children}>;
35 | }
36 |
37 | function Qux() {
38 | return <>O>;
39 | }
40 |
--------------------------------------------------------------------------------
/playground/cases/context.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Context",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | function HookConsumer() {
20 | const contextValue = React.useContext(MyContext);
21 |
22 | return ;
23 | }
24 |
25 | function ElementConsumer() {
26 | const memoCallback = React.useCallback(
27 | (contextValue: string) => ,
28 | []
29 | );
30 |
31 | return (
32 | <>
33 |
34 | {contextValue => }
35 |
36 | {memoCallback}
37 | >
38 | );
39 | }
40 |
41 | function Child({ value }: { value: string }) {
42 | return <>{value || "Fail: no value"}>;
43 | }
44 |
45 | function MemoPaypassConsumer() {
46 | return ;
47 | }
48 |
49 | const MemoWrapper = React.memo(function () {
50 | return ;
51 | });
52 | MemoWrapper.displayName = "MemoWrapper";
53 |
54 | function PaypassConsumerTarget() {
55 | React.useContext(MyContext);
56 | const contextValue = React.useContext(MyContext);
57 | const [state, setState] = React.useState(contextValue);
58 |
59 | if (state !== contextValue) {
60 | setState(contextValue);
61 | }
62 |
63 | return ;
64 | }
65 |
66 | const MyContext = React.createContext("Fail: waiting for context change");
67 | MyContext.displayName = "MyContext";
68 |
69 | function MyContextProvider({ children }: { children?: React.ReactNode }) {
70 | const [value, setValue] = React.useState(0);
71 |
72 | React.useEffect(() => {
73 | if (value < 5) {
74 | setValue(value + 1);
75 | }
76 | }, [value]);
77 |
78 | return (
79 | 0 ? "OK" + value : "Fail: waiting for context change"}
81 | >
82 | {children}
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/playground/cases/index.ts:
--------------------------------------------------------------------------------
1 | import { TestCase } from "../types";
2 |
3 | export default [
4 | getDefault(import("./class-component")),
5 | getDefault(import("./basic-nested-set-state")),
6 | getDefault(import("./props-changes")),
7 | getDefault(import("./basic-parent-element-change")),
8 | getDefault(import("./context")),
9 | getDefault(import("./hooks")),
10 | getDefault(import("./bailouts")),
11 | getDefault(import("./mount-unmount")),
12 | getDefault(import("./complex-composition-on-one-component")),
13 | getDefault(import("./set-state-by-event-handler")),
14 | getDefault(import("./use-effects")),
15 | getDefault(import("./suspense")),
16 | getDefault(import("./app")),
17 | getDefault(import("./screenshot-demo")),
18 | ];
19 |
20 | function getDefault(dynImport: Promise<{ default: TestCase }>) {
21 | return dynImport.then(exports => exports.default);
22 | }
23 |
--------------------------------------------------------------------------------
/playground/cases/mount-unmount.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Mount/unmount",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | const [isVisible, setIsVisible] = React.useState(false);
11 | const [isFirstRender, setIsFirstRender] = React.useState(true);
12 |
13 | React.useEffect(() => {
14 | let mounted = true;
15 | const timer = setTimeout(() => mounted && setIsFirstRender(false), 1);
16 |
17 | return () => {
18 | mounted = false;
19 | clearTimeout(timer);
20 | };
21 | }, [isFirstRender]);
22 |
23 | return (
24 | <>
25 |
28 | {(isVisible || isFirstRender) && }
29 | >
30 | );
31 | }
32 |
33 | function Child() {
34 | return <>OK>;
35 | }
36 |
--------------------------------------------------------------------------------
/playground/cases/props-changes.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Props changes",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | const [counter, setCounter] = React.useState(0);
11 | const [mounted, setMounted] = React.useState("Fail: waiting for mount");
12 | const [memoArray, memoObj] = React.useMemo(
13 | () => [[counter], { mounted, [mounted[0]]: "test" }],
14 | [mounted]
15 | );
16 |
17 | React.useEffect(() => {
18 | if (counter === 2) {
19 | setMounted("OK");
20 | } else {
21 | setCounter(counter + 1);
22 | }
23 | }, [counter]);
24 |
25 | return (
26 | <>
27 | }
32 | forwardRef={
33 | {
35 | /*noop*/
36 | }}
37 | />
38 | }
39 | lazy={}
40 | mix={}
41 | />
42 |
43 | >
44 | );
45 | }
46 |
47 | function Child({
48 | mounted,
49 | }: {
50 | mounted: string;
51 | array: any[];
52 | obj: Record;
53 | memo?: any;
54 | forwardRef?: any;
55 | lazy?: any;
56 | mix?: any;
57 | }) {
58 | return <>{mounted}>;
59 | }
60 | Child.displayName = "Child";
61 |
62 | const MemoChild = React.memo(Child);
63 | MemoChild.displayName = "MemoChild";
64 |
65 | function Stub() {
66 | return <>Stub>;
67 | }
68 |
69 | const Memo = React.memo(Stub);
70 | const ForwardRef = React.forwardRef(Stub);
71 | const Lazy = React.lazy(() => Promise.resolve({ default: Stub }));
72 | const Mix = React.memo(React.forwardRef(Stub));
73 | Memo.displayName = "MemoStub";
74 | ForwardRef.displayName = "ForwardRefStub";
75 | // Lazy.displayName = "LazyStub";
76 | Mix.displayName = "Mix";
77 |
--------------------------------------------------------------------------------
/playground/cases/set-state-by-event-handler.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Set state by event handler",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return ;
11 | }
12 |
13 | function Child() {
14 | const [ok, setOk] = React.useState(false);
15 |
16 | if (!ok) {
17 | return (
18 | setOk(true)}>
19 | Failed: waiting for click event
20 |
21 | );
22 | }
23 | return <>OK>;
24 | }
25 |
--------------------------------------------------------------------------------
/playground/cases/suspense.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "Using suspense",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | return (
11 | }>
12 |
13 |
14 | );
15 | }
16 |
17 | const LazyContent = React.lazy(() => Promise.resolve({ default: Content }));
18 | function Content() {
19 | return (
20 | }>
21 |
22 |
23 | );
24 | }
25 |
26 | const LazyContent2 = React.lazy(() => Promise.resolve({ default: Content2 }));
27 | function Content2() {
28 | return <>OK>;
29 | }
30 |
31 | function Spinner() {
32 | return <>Loading...>;
33 | }
34 |
--------------------------------------------------------------------------------
/playground/cases/use-effects.tsx:
--------------------------------------------------------------------------------
1 | import React from "../react";
2 | import { TestCase } from "../types";
3 |
4 | export default {
5 | title: "useEffect()/useLayoutEffect()",
6 | Root,
7 | } as TestCase;
8 |
9 | function Root() {
10 | const [isVisible, setIsVisible] = React.useState(true);
11 | const [, setState] = React.useState(0);
12 |
13 | React.useEffect(() => {
14 | setState(Date.now());
15 | }, []);
16 |
17 | return (
18 | <>
19 |
22 | {isVisible && }
23 | >
24 | );
25 | }
26 |
27 | function usePassiveEffects() {
28 | React.useEffect(() => {
29 | return () => {
30 | /* destroy */
31 | };
32 | });
33 |
34 | React.useEffect(() => {
35 | /* no destroy */
36 | });
37 | }
38 |
39 | function useLayoutEffects() {
40 | React.useLayoutEffect(() => {
41 | return () => {
42 | /* destroy */
43 | };
44 | });
45 |
46 | React.useLayoutEffect(() => {
47 | /* no destroy */
48 | });
49 | }
50 |
51 | function useEffects() {
52 | usePassiveEffects();
53 | useLayoutEffects();
54 | }
55 |
56 | function Child() {
57 | useEffects();
58 |
59 | return <>OK>;
60 | }
61 |
--------------------------------------------------------------------------------
/playground/create-test-case-wrapper.ts:
--------------------------------------------------------------------------------
1 | import ReactDOM from "./react-dom";
2 | import { createElement } from "./dom-utils";
3 | import { TestCase } from "./types";
4 |
5 | const emulateEventAttribute = "data-send-event";
6 |
7 | function emulateEvent(target: HTMLElement) {
8 | const value = target.getAttribute(emulateEventAttribute);
9 |
10 | switch (value) {
11 | case "click":
12 | target.click();
13 | break;
14 | default:
15 | console.warn(
16 | `Unknown event type "${value}" in "${emulateEventAttribute}" attribute`
17 | );
18 | }
19 | }
20 |
21 | export default function (testcase: TestCase) {
22 | let reactRoot: any;
23 | let reactRootEl: HTMLElement;
24 | const rootEl = createElement("div", "case-wrapper", [
25 | createElement("h2", null, [createElement("span", null, testcase.title)]),
26 | (reactRootEl = createElement("div", {
27 | id: testcase.title,
28 | class: "content",
29 | })),
30 | ]);
31 |
32 | let observing = false;
33 | const observer = new MutationObserver(mutations => {
34 | for (const mutation of mutations) {
35 | switch (mutation.type) {
36 | case "attributes":
37 | if (mutation.attributeName === emulateEventAttribute) {
38 | emulateEvent(mutation.target as HTMLElement);
39 | }
40 | break;
41 | case "childList":
42 | for (const node of Array.from(mutation.addedNodes)) {
43 | const target = node as HTMLElement;
44 | if (
45 | target.nodeType === 1 &&
46 | target.hasAttribute(emulateEventAttribute)
47 | ) {
48 | emulateEvent(target);
49 | }
50 | }
51 | break;
52 | }
53 | }
54 | });
55 |
56 | return {
57 | id: testcase.title.replace(/\s+/g, "-"),
58 | testcase,
59 | render(containerEl: HTMLElement, element: JSX.Element) {
60 | if (!containerEl.contains(rootEl)) {
61 | containerEl.append(rootEl);
62 | }
63 |
64 | if (!observing) {
65 | observing = true;
66 | observer.observe(reactRootEl, {
67 | subtree: true,
68 | childList: true,
69 | attributes: true,
70 | attributeFilter: [emulateEventAttribute],
71 | });
72 | }
73 |
74 | if ((ReactDOM as any).createRoot) {
75 | // React 18
76 | reactRoot = (ReactDOM as any).createRoot(reactRootEl);
77 | reactRoot.render(element);
78 | } else {
79 | // React prior 18
80 | // eslint-disable-next-line react/no-deprecated
81 | ReactDOM.render(element, reactRootEl);
82 | }
83 | },
84 | dispose() {
85 | observing = false;
86 | observer.disconnect();
87 | if (reactRoot) {
88 | reactRoot.unmount();
89 | } else {
90 | // eslint-disable-next-line react/no-deprecated
91 | ReactDOM.unmountComponentAtNode(reactRootEl);
92 | }
93 | rootEl.remove();
94 | },
95 | };
96 | }
97 |
--------------------------------------------------------------------------------
/playground/data-client-example.js:
--------------------------------------------------------------------------------
1 | // import * as rrt from "react-render-tracker/data-client";
2 | import * as rrt from "./rrt-data-client.js";
3 |
4 | function filterCommits(events) {
5 | return events.filter(({ op }) => op === "commit-start");
6 | }
7 |
8 | rrt.isReady().then(() => {
9 | setTimeout(async () => {
10 | const initialEvents = await rrt.getEvents();
11 | console.log("[data-client-example] Skip initial events:", initialEvents);
12 | console.log(
13 | "[data-client-example] Initial commits:",
14 | filterCommits(initialEvents)
15 | );
16 |
17 | rrt.subscribeNewEvents(newEvents => {
18 | console.log("[data-client-example] New events", newEvents);
19 | console.log(
20 | "[data-client-example] Commits in new events",
21 | filterCommits(newEvents)
22 | );
23 | });
24 | }, 350);
25 | });
26 |
--------------------------------------------------------------------------------
/playground/dom-utils.ts:
--------------------------------------------------------------------------------
1 | const { hasOwnProperty } = Object.prototype;
2 |
3 | type EventHandler = (this: Element, evt: Event) => void;
4 | type Attrs = {
5 | [key in keyof HTMLElementEventMap as `on${key}`]?: EventHandler<
6 | HTMLElementTagNameMap[TagName],
7 | HTMLElementEventMap[key]
8 | >;
9 | } & {
10 | [key: string]: any | undefined; // TODO: replace "any" with "string"
11 | };
12 |
13 | export function createElement(
14 | tag: TagName,
15 | attrs: Attrs | string | null,
16 | children?: (Node | string)[] | string
17 | ) {
18 | const el = document.createElement(tag);
19 |
20 | if (typeof attrs === "string") {
21 | attrs = {
22 | class: attrs,
23 | };
24 | }
25 |
26 | for (const attrName in attrs) {
27 | if (typeof attrName === "string" && hasOwnProperty.call(attrs, attrName)) {
28 | const value = attrs[attrName];
29 |
30 | if (typeof value === "undefined") {
31 | continue;
32 | }
33 |
34 | if (typeof value === "function") {
35 | el.addEventListener(attrName.slice(2), value);
36 | } else {
37 | el.setAttribute(attrName, value);
38 | }
39 | }
40 | }
41 |
42 | if (Array.isArray(children)) {
43 | el.append(...children);
44 | } else if (typeof children === "string") {
45 | el.innerHTML = children;
46 | }
47 |
48 | return el;
49 | }
50 |
51 | export function createText(text: any) {
52 | return document.createTextNode(String(text));
53 | }
54 |
55 | export function createFragment(...children: (Node | string)[]) {
56 | const fragment = document.createDocumentFragment();
57 |
58 | fragment.append(...children);
59 |
60 | return fragment;
61 | }
62 |
--------------------------------------------------------------------------------
/playground/react-dom.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from "react-dom";
2 | export default ReactDOM;
3 | // export default window.ReactDOM;
4 |
--------------------------------------------------------------------------------
/playground/react.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | export default React;
3 | // export default window.React;
4 |
--------------------------------------------------------------------------------
/playground/types.ts:
--------------------------------------------------------------------------------
1 | export type TestCase = {
2 | title: string;
3 | Root: React.FunctionComponent<{ title: string }>;
4 | };
5 |
6 | declare module "rempl" {
7 | export function getHost(): {
8 | activate(): void;
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/scripts/build-gh-pages.js:
--------------------------------------------------------------------------------
1 | const { copyFileSync, mkdirSync } = require("fs");
2 | const { buildPlayground, buildBundle, buildDataClient } = require("./build");
3 |
4 | mkdirSync(".gh-pages", { recursive: true });
5 | copyFileSync("playground/index.html", ".gh-pages/index.html");
6 | copyFileSync("playground/index.css", ".gh-pages/index.css");
7 | copyFileSync(
8 | "playground/data-client-example.js",
9 | ".gh-pages/data-client-example.js"
10 | );
11 | build(buildPlayground, ".gh-pages/index.js");
12 | build(buildBundle, ".gh-pages/react-render-tracker.js");
13 | build(buildDataClient, ".gh-pages/rrt-data-client.js");
14 |
15 | function build(subject, outfile) {
16 | subject({
17 | logLevel: "info",
18 | write: true,
19 | outfile,
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/scripts/server.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const cors = require("cors");
3 | const path = require("path");
4 | const fs = require("fs");
5 | const openInEditor = require("express-open-in-editor");
6 | const {
7 | buildPublisher,
8 | buildSubscriber,
9 | buildPlayground,
10 | buildDataUtils,
11 | buildBundle,
12 | buildDataClient,
13 | } = require("./build");
14 | const playgroundDir = path.join(__dirname, "../playground");
15 |
16 | const app = express();
17 | app.use(cors());
18 | app.get("/", html(path.join(playgroundDir, "index.html")));
19 | app.get("/index.html", html(path.join(playgroundDir, "index.html")));
20 | app.use("/dist", express.static(path.join(__dirname, "../dist")));
21 | app.get("/open-in-editor", openInEditor());
22 | app.use(express.static(playgroundDir));
23 |
24 | app.listen(process.env.PORT || 3000, function () {
25 | const host = `http://localhost:${this.address().port}`;
26 |
27 | console.log(`Server listen on ${host}`);
28 | for (let [url, generator] of Object.entries({
29 | "/index.js": () => buildPlayground(),
30 | "/react-render-tracker.js": () =>
31 | buildPublisher({ define: { "import.meta.url": JSON.stringify(host) } }),
32 | "/publisher.js": () =>
33 | buildPublisher({ define: { "import.meta.url": JSON.stringify(host) } }),
34 | "/subscriber.js": () => buildSubscriber(),
35 | "/rrt-data-client.js": () => buildDataClient({ sourcemap: "inline" }),
36 | "/rrt-data-utils.js": () => buildDataUtils({ sourcemap: "inline" }),
37 | "/dist/react-render-tracker.js": () => buildBundle(),
38 | "/dist/data-client.js": () => buildDataClient(),
39 | "/dist/data-utils.js": () => buildDataUtils(),
40 | })) {
41 | app.get(url, asyncResponse(generator));
42 | }
43 | });
44 |
45 | function html(filepath) {
46 | return (req, res) => {
47 | res.type("text/html");
48 | res.send(
49 | fs.readFileSync(filepath, "utf8").replace(/\{cwd\}/g, process.cwd())
50 | );
51 | res.end();
52 | };
53 | }
54 |
55 | function asyncResponse(asyncFn, contentType = "text/javascript") {
56 | return async (req, res) => {
57 | // console.log("request", req.url);
58 | try {
59 | const content = await asyncFn();
60 | res.type(contentType);
61 | res.send(content);
62 | res.end();
63 | } catch (e) {
64 | res.status(500);
65 | res.end(e.message);
66 | }
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/src/common/constants.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FiberType,
3 | FiberRootMode,
4 | TrackingObjectType,
5 | TrackingObjectTypeHook,
6 | } from "common-types";
7 |
8 | export const ToolId = "React Render Tracker";
9 |
10 | export const ElementTypeClass: FiberType = 1;
11 | export const ElementTypeFunction: FiberType = 2;
12 | export const ElementTypeMemo: FiberType = 3;
13 | export const ElementTypeForwardRef: FiberType = 4;
14 | export const ElementTypeProvider: FiberType = 5;
15 | export const ElementTypeConsumer: FiberType = 6;
16 | export const ElementTypeHostRoot: FiberType = 7;
17 | export const ElementTypeHostComponent: FiberType = 8;
18 | export const ElementTypeHostText: FiberType = 9;
19 | export const ElementTypeHostPortal: FiberType = 10;
20 | export const ElementTypeSuspense: FiberType = 11;
21 | export const ElementTypeSuspenseList: FiberType = 12;
22 | export const ElementTypeProfiler: FiberType = 13;
23 | export const ElementTypeOtherOrUnknown: FiberType = 14;
24 |
25 | export const FiberTypeName: Record = {
26 | [ElementTypeClass]: "Class component",
27 | [ElementTypeFunction]: "Function component",
28 | [ElementTypeMemo]: "Memo",
29 | [ElementTypeForwardRef]: "ForwardRef",
30 | [ElementTypeProvider]: "Provider",
31 | [ElementTypeConsumer]: "Consumer",
32 | [ElementTypeHostRoot]: "Render root",
33 | [ElementTypeHostComponent]: "Host component",
34 | [ElementTypeHostText]: "Host text",
35 | [ElementTypeHostPortal]: "Host portal",
36 | [ElementTypeSuspense]: "Suspense",
37 | [ElementTypeSuspenseList]: "Suspense list",
38 | [ElementTypeProfiler]: "Profiler",
39 | [ElementTypeOtherOrUnknown]: "Unknown",
40 | };
41 |
42 | export const LegacyRoot: FiberRootMode = 0;
43 | export const ConcurrentRoot: FiberRootMode = 1;
44 |
45 | export const fiberRootMode: Record = {
46 | [LegacyRoot]: "Legacy Mode",
47 | [ConcurrentRoot]: "Concurrent Mode",
48 | };
49 |
50 | // Tracking object types must a power of 2
51 | export const TrackingObjectFiber: TrackingObjectType = 0;
52 | export const TrackingObjectAlternate: TrackingObjectType = 1;
53 | export const TrackingObjectStateNode: TrackingObjectType = 2;
54 | export const TrackingObjectHook: TrackingObjectTypeHook = 3;
55 |
56 | export const TrackingObjectTypeName: Record = {
57 | [TrackingObjectFiber]: "fiber",
58 | [TrackingObjectAlternate]: "alternate",
59 | [TrackingObjectStateNode]: "stateNode",
60 | [TrackingObjectHook]: "hook",
61 | };
62 |
63 | export const FeatureMemLeaks = false;
64 | export const FeatureCommits = false;
65 |
--------------------------------------------------------------------------------
/src/data/fiber-dataset.ts:
--------------------------------------------------------------------------------
1 | import { Message } from "common-types";
2 | import {
3 | Commit,
4 | FiberTypeDef,
5 | FiberTypeStat,
6 | LinkedEvent,
7 | MessageFiber,
8 | } from "../common/consumer-types";
9 | import { processEvents } from "./process-events";
10 | import { SubscribeMap, Subset, SubsetSplit } from "./subscription";
11 | import { Tree } from "./tree";
12 |
13 | export function createFiberDataset(events: Message[] = []) {
14 | const allEvents: Message[] = [];
15 | const linkedEvents = new WeakMap();
16 | const commitById = new SubscribeMap();
17 | const fiberById = new SubscribeMap();
18 | const fiberTypeDefById = new SubscribeMap();
19 | const fiberTypeStat = new SubscribeMap();
20 | const fibersByTypeId = new SubsetSplit();
21 | const fibersByProviderId = new SubsetSplit();
22 | const leakedFibers = new Subset();
23 | const parentTree = new Tree();
24 | const parentTreeIncludeUnmounted = new Tree();
25 | const ownerTree = new Tree();
26 | const ownerTreeIncludeUnmounted = new Tree();
27 |
28 | const dataset = {
29 | allEvents,
30 | linkedEvents,
31 | commitById,
32 | fiberById,
33 | fiberTypeDefById,
34 | fiberTypeStat,
35 | fibersByTypeId,
36 | fibersByProviderId,
37 | leakedFibers,
38 | parentTree,
39 | parentTreeIncludeUnmounted,
40 | ownerTree,
41 | ownerTreeIncludeUnmounted,
42 | appendEvents(events: Message[]) {
43 | processEvents(events, allEvents, dataset);
44 | },
45 | selectTree(groupByParent: boolean, includeUnmounted: boolean): Tree {
46 | return groupByParent
47 | ? includeUnmounted
48 | ? parentTreeIncludeUnmounted
49 | : parentTree
50 | : includeUnmounted
51 | ? ownerTreeIncludeUnmounted
52 | : ownerTree;
53 | },
54 | };
55 |
56 | if (Array.isArray(events) && events.length > 0) {
57 | dataset.appendEvents(events);
58 | }
59 |
60 | return dataset;
61 | }
62 |
--------------------------------------------------------------------------------
/src/data/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./fiber-dataset";
2 | export * from "./process-events";
3 | export * from "./subscription";
4 | export * from "./tree";
5 |
--------------------------------------------------------------------------------
/src/publisher/config.ts:
--------------------------------------------------------------------------------
1 | type OpenSourceSettings = {
2 | pattern: string;
3 | projectRoot: string;
4 | basedir: string;
5 | basedirJsx: string;
6 | };
7 | let config: { inpage?: boolean; openSourceLoc?: OpenSourceSettings } = {};
8 |
9 | function normBasedir(basedir: string) {
10 | basedir = basedir
11 | .trim()
12 | .replace(/\\/g, "/")
13 | .replace(/^\/+|\/+$/g, "");
14 | basedir = basedir ? `/${basedir}/` : "/";
15 | return basedir;
16 | }
17 |
18 | function normOpenSourceLoc(
19 | value: Partial | string | undefined
20 | ): OpenSourceSettings | undefined {
21 | if (!value) {
22 | return undefined;
23 | }
24 |
25 | let {
26 | // eslint-disable-next-line prefer-const
27 | pattern,
28 | projectRoot = "",
29 | basedir = "",
30 | basedirJsx = null,
31 | } = typeof value === "string" ? { pattern: value } : value;
32 |
33 | if (typeof pattern !== "string") {
34 | return undefined;
35 | } else {
36 | pattern = /^[a-z]+:/.test(pattern)
37 | ? pattern
38 | : new URL(pattern, location.origin).href;
39 | }
40 |
41 | if (typeof projectRoot !== "string") {
42 | projectRoot = "";
43 | } else {
44 | projectRoot = projectRoot.trim().replace(/\\/g, "/").replace(/\/+$/, "");
45 | }
46 |
47 | basedir = typeof basedir !== "string" ? "" : normBasedir(basedir);
48 | basedirJsx =
49 | typeof basedirJsx !== "string" ? basedir : normBasedir(basedirJsx);
50 |
51 | return { pattern, projectRoot, basedir, basedirJsx };
52 | }
53 |
54 | if (typeof document !== "undefined") {
55 | const rawConfig = document.currentScript?.dataset.config;
56 |
57 | if (typeof rawConfig === "string") {
58 | try {
59 | const parsedConfig: Omit & {
60 | openSourceLoc?: string | Partial;
61 | } = Function(`return{${rawConfig}}`)();
62 | const parsedOpenSourceSettings = normOpenSourceLoc(
63 | parsedConfig.openSourceLoc
64 | );
65 |
66 | config = {
67 | ...parsedConfig,
68 | openSourceLoc: parsedOpenSourceSettings,
69 | };
70 | } catch (error) {
71 | console.error(
72 | `[React Render Tracker] Config parse error\nConfig: ${rawConfig}\n`,
73 | error
74 | );
75 | }
76 | }
77 | }
78 |
79 | export default config;
80 |
--------------------------------------------------------------------------------
/src/publisher/index.ts:
--------------------------------------------------------------------------------
1 | import { getHost } from "rempl";
2 | import config from "./config";
3 | import { installReactDevtoolsHook } from "./react-devtools-hook";
4 | import {
5 | publishReactRenderer,
6 | publishReactUnsupportedRenderer,
7 | remoteCommands,
8 | } from "./rempl-publisher";
9 | import { attach } from "./react-integration";
10 |
11 | export const hook = installReactDevtoolsHook(
12 | window,
13 | (id, renderer) =>
14 | attach(renderer, publishReactRenderer(id, renderer), remoteCommands),
15 | publishReactUnsupportedRenderer
16 | );
17 |
18 | if (config.inpage) {
19 | getHost().activate();
20 | }
21 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/index.ts:
--------------------------------------------------------------------------------
1 | import { createIntegrationCore } from "./core";
2 | import { createReactDevtoolsHookHandlers } from "./devtools-hook-handlers";
3 | import { createReactInteractionApi } from "./interaction-api";
4 | import { createHighlightApi } from "./highlight-api";
5 | import {
6 | ReactInternals,
7 | ReactIntegrationApi,
8 | RecordEventHandler,
9 | RemoteCommandsApi,
10 | } from "../types";
11 | import { createDispatcherTrap } from "./dispatcher-trap";
12 |
13 | export function attach(
14 | renderer: ReactInternals,
15 | recordEvent: RecordEventHandler,
16 | removeCommands: (api: RemoteCommandsApi) => void
17 | ): ReactIntegrationApi {
18 | const integrationCore = createIntegrationCore(renderer, recordEvent);
19 | const dispatcherApi = createDispatcherTrap(renderer, integrationCore);
20 | const interactionApi = createReactInteractionApi(integrationCore);
21 | const highlightApi = createHighlightApi(interactionApi);
22 |
23 | removeCommands({
24 | highlightApi,
25 | ...integrationCore.memoryLeaksApi,
26 | });
27 |
28 | return {
29 | ...createReactDevtoolsHookHandlers(
30 | integrationCore,
31 | dispatcherApi,
32 | recordEvent
33 | ),
34 | ...interactionApi,
35 | ...integrationCore.memoryLeaksApi,
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/arrayDiff.ts:
--------------------------------------------------------------------------------
1 | import { TransferArrayDiff } from "../../types";
2 |
3 | export function arrayDiff(
4 | prev: any,
5 | next: any
6 | ): TransferArrayDiff | false | undefined {
7 | if (Array.isArray(prev) && Array.isArray(next)) {
8 | let eqLeft = 0;
9 | let eqRight = 0;
10 |
11 | for (let i = 0; i < prev.length; i++, eqLeft++) {
12 | if (!Object.is(prev[i], next[i])) {
13 | break;
14 | }
15 | }
16 |
17 | for (let i = prev.length - 1; i > eqLeft; i--, eqRight++) {
18 | if (!Object.is(prev[i], next[i])) {
19 | break;
20 | }
21 | }
22 |
23 | return prev.length !== next.length || eqLeft !== prev.length
24 | ? { prevLength: prev.length, nextLength: next.length, eqLeft, eqRight }
25 | : false;
26 | }
27 |
28 | return undefined;
29 | }
30 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const CONCURRENT_MODE_NUMBER = 0xeacf;
2 | export const CONCURRENT_MODE_SYMBOL_STRING = "Symbol(react.concurrent_mode)";
3 |
4 | export const CONTEXT_NUMBER = 0xeace;
5 | export const CONTEXT_SYMBOL_STRING = "Symbol(react.context)";
6 |
7 | export const DEPRECATED_ASYNC_MODE_SYMBOL_STRING = "Symbol(react.async_mode)";
8 |
9 | export const ELEMENT_NUMBER = 0xeac7;
10 | export const ELEMENT_SYMBOL_STRING = "Symbol(react.element)";
11 |
12 | export const DEBUG_TRACING_MODE_NUMBER = 0xeae1;
13 | export const DEBUG_TRACING_MODE_SYMBOL_STRING =
14 | "Symbol(react.debug_trace_mode)";
15 |
16 | export const FORWARD_REF_NUMBER = 0xead0;
17 | export const FORWARD_REF_SYMBOL_STRING = "Symbol(react.forward_ref)";
18 |
19 | export const FRAGMENT_NUMBER = 0xeacb;
20 | export const FRAGMENT_SYMBOL_STRING = "Symbol(react.fragment)";
21 |
22 | export const LAZY_NUMBER = 0xead4;
23 | export const LAZY_SYMBOL_STRING = "Symbol(react.lazy)";
24 |
25 | export const MEMO_NUMBER = 0xead3;
26 | export const MEMO_SYMBOL_STRING = "Symbol(react.memo)";
27 |
28 | export const OPAQUE_ID_NUMBER = 0xeae0;
29 | export const OPAQUE_ID_SYMBOL_STRING = "Symbol(react.opaque.id)";
30 |
31 | export const PORTAL_NUMBER = 0xeaca;
32 | export const PORTAL_SYMBOL_STRING = "Symbol(react.portal)";
33 |
34 | export const PROFILER_NUMBER = 0xead2;
35 | export const PROFILER_SYMBOL_STRING = "Symbol(react.profiler)";
36 |
37 | export const PROVIDER_NUMBER = 0xeacd;
38 | export const PROVIDER_SYMBOL_STRING = "Symbol(react.provider)";
39 |
40 | export const SCOPE_NUMBER = 0xead7;
41 | export const SCOPE_SYMBOL_STRING = "Symbol(react.scope)";
42 |
43 | export const STRICT_MODE_NUMBER = 0xeacc;
44 | export const STRICT_MODE_SYMBOL_STRING = "Symbol(react.strict_mode)";
45 |
46 | export const SUSPENSE_NUMBER = 0xead1;
47 | export const SUSPENSE_SYMBOL_STRING = "Symbol(react.suspense)";
48 |
49 | export const SUSPENSE_LIST_NUMBER = 0xead8;
50 | export const SUSPENSE_LIST_SYMBOL_STRING = "Symbol(react.suspense_list)";
51 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/getDisplayName.ts:
--------------------------------------------------------------------------------
1 | const cachedDisplayNames = new WeakMap();
2 | const usedDisplayNames = new Map();
3 |
4 | export function getDisplayName(type: any, kind = ""): string {
5 | const displayNameFromCache = cachedDisplayNames.get(type);
6 | if (typeof displayNameFromCache === "string") {
7 | return displayNameFromCache;
8 | }
9 |
10 | let displayName;
11 |
12 | // The displayName property is not guaranteed to be a string.
13 | // It's only safe to use for our purposes if it's a string.
14 | // github.com/facebook/react-devtools/issues/803
15 | if (type) {
16 | if (typeof type.displayName === "string") {
17 | displayName = type.displayName;
18 | } else if (typeof type.name === "string" && type.name !== "") {
19 | displayName = type.name;
20 | }
21 | }
22 |
23 | if (!displayName) {
24 | displayName = "Anonymous" + kind;
25 | }
26 |
27 | if (usedDisplayNames.has(displayName)) {
28 | const num = usedDisplayNames.get(displayName) || 2;
29 | usedDisplayNames.set(displayName, num + 1);
30 | displayName += `\`${String(num)}`;
31 | } else {
32 | usedDisplayNames.set(displayName, 2);
33 | }
34 |
35 | cachedDisplayNames.set(type, displayName);
36 |
37 | return displayName;
38 | }
39 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/getDisplayNameFromJsx.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CONTEXT_SYMBOL_STRING,
3 | FORWARD_REF_SYMBOL_STRING,
4 | FRAGMENT_SYMBOL_STRING,
5 | LAZY_SYMBOL_STRING,
6 | MEMO_SYMBOL_STRING,
7 | PROVIDER_SYMBOL_STRING,
8 | } from "./constants";
9 | import { getDisplayName } from "./getDisplayName";
10 |
11 | // function resolveDisplayName(value: any) {
12 | // return typeof value === "function"
13 | // ? getDisplayName(value)
14 | // : getDisplayNameFromJsx(value);
15 | // }
16 |
17 | export function getDisplayNameFromJsx(type: any) {
18 | if (type) {
19 | switch (typeof type) {
20 | case "string":
21 | return type;
22 |
23 | case "function":
24 | return getDisplayName(type);
25 |
26 | case "symbol":
27 | if (String(type) === FRAGMENT_SYMBOL_STRING) {
28 | return "";
29 | }
30 |
31 | default:
32 | if (type.$$typeof) {
33 | switch (String(type.$$typeof)) {
34 | case FRAGMENT_SYMBOL_STRING:
35 | return "";
36 |
37 | case MEMO_SYMBOL_STRING:
38 | // name = resolveDisplayName(type.type);
39 | // break;
40 | case FORWARD_REF_SYMBOL_STRING:
41 | // name = resolveDisplayName(type.render);
42 | // break;
43 | case LAZY_SYMBOL_STRING: // _ctor
44 | return getDisplayName(type);
45 |
46 | case PROVIDER_SYMBOL_STRING: {
47 | const resolvedContext = type._context || type.context;
48 | return `${getDisplayName(resolvedContext, "Context")}.Provider`;
49 | }
50 |
51 | case CONTEXT_SYMBOL_STRING: {
52 | const resolvedContext = type._context || type.context;
53 | return `${getDisplayName(resolvedContext, "Context")}.Provider`;
54 | }
55 |
56 | default:
57 | return String(type.$$typeof);
58 | }
59 | }
60 | }
61 | }
62 |
63 | return "Unknown";
64 | }
65 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/getFiberFlags.ts:
--------------------------------------------------------------------------------
1 | import { Fiber } from "../../types";
2 |
3 | export function getFiberFlags(fiber: Fiber): number {
4 | // The name of this field changed from "effectTag" to "flags"
5 | return (
6 | (fiber.flags !== undefined ? fiber.flags : (fiber as any).effectTag) ?? 0
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/isPlainObject.ts:
--------------------------------------------------------------------------------
1 | export function isPlainObject(value: any) {
2 | return (
3 | value !== null &&
4 | typeof value === "object" &&
5 | value.constructor === Object &&
6 | typeof value.$$typeof !== "symbol"
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/objectDiff.ts:
--------------------------------------------------------------------------------
1 | import { TransferObjectDiff } from "../../types";
2 | import { isPlainObject } from "./isPlainObject";
3 | import { simpleValueSerialization } from "./simpleValueSerialization";
4 |
5 | const { hasOwnProperty } = Object.prototype;
6 |
7 | export function objectDiff(
8 | prev: any,
9 | next: any
10 | ): TransferObjectDiff | false | undefined {
11 | if (isPlainObject(prev) && isPlainObject(next)) {
12 | const sample = [];
13 | let keys = 0;
14 | let diffKeys = 0;
15 |
16 | for (const name in prev) {
17 | if (hasOwnProperty.call(prev, name)) {
18 | keys++;
19 | if (!hasOwnProperty.call(next, name)) {
20 | diffKeys++ < 3 &&
21 | sample.push({ name, prev: simpleValueSerialization(prev[name]) });
22 | } else if (!Object.is(prev[name], next[name])) {
23 | diffKeys++ < 3 &&
24 | sample.push({
25 | name,
26 | prev: simpleValueSerialization(prev[name]),
27 | next: simpleValueSerialization(next[name]),
28 | });
29 | }
30 | }
31 | }
32 |
33 | for (const name in next) {
34 | if (hasOwnProperty.call(next, name)) {
35 | if (!hasOwnProperty.call(prev, name)) {
36 | keys++;
37 | diffKeys++ < 3 &&
38 | sample.push({ name, next: simpleValueSerialization(next[name]) });
39 | }
40 | }
41 | }
42 |
43 | return diffKeys > 0 ? { keys, diffKeys, sample } : false;
44 | }
45 |
46 | return undefined;
47 | }
48 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/separateDisplayNameAndHOCs.ts:
--------------------------------------------------------------------------------
1 | import { FiberType } from "../../types";
2 | import {
3 | ElementTypeClass,
4 | ElementTypeFunction,
5 | ElementTypeMemo,
6 | ElementTypeForwardRef,
7 | } from "../../../common/constants";
8 |
9 | export function separateDisplayNameAndHOCs(
10 | displayName: string | null,
11 | type: FiberType
12 | ): { displayName: string | null; hocDisplayNames: string[] | null } {
13 | if (displayName === null) {
14 | return { displayName, hocDisplayNames: null };
15 | }
16 |
17 | let hocDisplayNames: string[] = [];
18 |
19 | if (
20 | type === ElementTypeClass ||
21 | type === ElementTypeFunction ||
22 | type === ElementTypeForwardRef ||
23 | type === ElementTypeMemo
24 | ) {
25 | if (displayName.includes("(")) {
26 | const matches = displayName.match(/[^()]+/g);
27 |
28 | if (matches !== null) {
29 | displayName = matches.pop() || "";
30 | hocDisplayNames = matches;
31 | }
32 | }
33 | }
34 |
35 | if (type === ElementTypeMemo) {
36 | hocDisplayNames.unshift("Memo");
37 | } else if (type === ElementTypeForwardRef) {
38 | hocDisplayNames.unshift("ForwardRef");
39 | }
40 |
41 | return {
42 | displayName,
43 | hocDisplayNames: hocDisplayNames.length > 0 ? hocDisplayNames : null,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/publisher/react-integration/utils/simpleValueSerialization.ts:
--------------------------------------------------------------------------------
1 | import { getDisplayNameFromJsx } from "./getDisplayNameFromJsx";
2 |
3 | const { hasOwnProperty, toString } = Object.prototype;
4 |
5 | function isPlainObject(value: any) {
6 | return (
7 | typeof value === "object" && value !== null && value.constructor === Object
8 | );
9 | }
10 |
11 | export function simpleValueSerialization(value: any) {
12 | switch (typeof value) {
13 | case "boolean":
14 | case "undefined":
15 | case "number":
16 | case "bigint":
17 | case "symbol":
18 | return String(value);
19 |
20 | case "function":
21 | return "ƒn";
22 |
23 | case "string":
24 | return JSON.stringify(
25 | value.length > 20 ? value.slice(0, 20) + "…" : value
26 | );
27 |
28 | case "object":
29 | if (value === null) {
30 | return "null";
31 | }
32 |
33 | if (Array.isArray(value)) {
34 | return value.length ? "[…]" : "[]";
35 | }
36 |
37 | if (
38 | typeof value.$$typeof === "symbol" &&
39 | String(value.$$typeof) === "Symbol(react.element)"
40 | ) {
41 | const name = getDisplayNameFromJsx(value.type);
42 |
43 | return `<${name}${Object.keys(value.props).length > 0 ? " …" : ""}/>`;
44 | }
45 |
46 | if (isPlainObject(value)) {
47 | for (const key in value) {
48 | if (hasOwnProperty.call(value, key)) {
49 | return "{…}";
50 | }
51 | }
52 |
53 | return "{}";
54 | }
55 |
56 | const tagString = toString.call(value).slice(8, -1);
57 |
58 | if (tagString === "Object") {
59 | const constructor = Object.getPrototypeOf(value)?.constructor;
60 |
61 | if (typeof constructor === "function") {
62 | return `${constructor.displayName || constructor.name || ""}{…}`;
63 | }
64 | }
65 |
66 | return `${tagString}{…}`;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/publisher/utils/renderer-info.ts:
--------------------------------------------------------------------------------
1 | import versionGreaterThanOrEqual from "semver/functions/gte";
2 | import { ReactInternals, RendererBundleType } from "../types";
3 |
4 | const MIN_SUPPORTED_VERSION = "16.9.0";
5 | const BUNDLE_TYPE_PROD = 0;
6 | const BUNDLE_TYPE_DEV = 1;
7 |
8 | type RendererInfoProps = Pick<
9 | ReactInternals,
10 | "rendererPackageName" | "version" | "bundleType" | "injectProfilingHooks"
11 | >;
12 |
13 | function resolveBundleType(
14 | bundleType: number | undefined,
15 | version: string,
16 | injectProfilingHooks: (() => void) | undefined
17 | ): RendererBundleType {
18 | if (bundleType === BUNDLE_TYPE_DEV) {
19 | return "development";
20 | }
21 |
22 | if (bundleType === BUNDLE_TYPE_PROD) {
23 | if (
24 | version !== "unknown" &&
25 | versionGreaterThanOrEqual(version, "18.0.0") &&
26 | typeof injectProfilingHooks === "function"
27 | ) {
28 | return "profiling";
29 | }
30 |
31 | return "production";
32 | }
33 |
34 | return "unknown";
35 | }
36 |
37 | export function getRendererInfo({
38 | rendererPackageName,
39 | version,
40 | bundleType,
41 | injectProfilingHooks,
42 | }: RendererInfoProps) {
43 | if (typeof version !== "string" || !/^\d+\.\d+\.\d+(-\S+)?$/.test(version)) {
44 | version = "unknown";
45 | }
46 |
47 | return {
48 | name: rendererPackageName || "unknown",
49 | version,
50 | bundleType: resolveBundleType(bundleType, version, injectProfilingHooks),
51 | };
52 | }
53 |
54 | export function isUnsupportedRenderer(renderer: RendererInfoProps) {
55 | const info = getRendererInfo(renderer);
56 |
57 | if (info.name !== "react-dom" && info.name !== "react-native-renderer") {
58 | return {
59 | reason: `Unsupported renderer name, only "react-dom" is supported`,
60 | info,
61 | };
62 | }
63 |
64 | if (
65 | info.version === "unknown" ||
66 | !versionGreaterThanOrEqual(info.version, MIN_SUPPORTED_VERSION)
67 | ) {
68 | return {
69 | reason: `Unsupported renderer version, only v${MIN_SUPPORTED_VERSION}+ is supported`,
70 | info,
71 | };
72 | }
73 |
74 | // if (info.bundleType !== "development") {
75 | // return {
76 | // reason: `Unsupported renderer bundle type "${info.bundleType}" (${renderer.bundleType}), only "development" (${BUNDLE_TYPE_DEV}) is supported`,
77 | // info,
78 | // };
79 | // }
80 |
81 | return false;
82 | }
83 |
--------------------------------------------------------------------------------
/src/ui/App.css:
--------------------------------------------------------------------------------
1 | @keyframes app-appear {
2 | from {
3 | opacity: 0;
4 | }
5 | to {
6 | opacity: 1;
7 | }
8 | }
9 |
10 | .app {
11 | display: grid;
12 | grid-template-areas:
13 | "appbar"
14 | "page"
15 | "statebar"
16 | "statusbar";
17 | grid-template-rows: auto 1fr auto;
18 | height: 100vh;
19 | overflow: hidden;
20 | animation: 0.1s 1 app-appear;
21 | }
22 |
23 | .app-page {
24 | overflow: hidden;
25 | }
26 |
--------------------------------------------------------------------------------
/src/ui/components/appbar/AppBar.css:
--------------------------------------------------------------------------------
1 | .app-bar {
2 | grid-area: appbar;
3 | display: flex;
4 | background-color: #fcfcfc;
5 | }
6 | .app-bar::after {
7 | content: "";
8 | flex: 1;
9 | border-bottom: 1px solid #ddd;
10 | }
11 |
12 | .app-bar__tab {
13 | font-size: 11px;
14 | padding: 0px 6px;
15 | border-right: 1px solid #d4d4d4;
16 | border-bottom: 1px solid #ddd;
17 | background-color: #f4f4f4;
18 | color: #888;
19 | user-select: none;
20 | }
21 | .app-bar__tab:hover {
22 | background-color: white;
23 | cursor: pointer;
24 | }
25 | .app-bar__tab.selected {
26 | color: inherit;
27 | border-bottom-color: transparent;
28 | background-color: white;
29 | cursor: default;
30 | }
31 | .app-bar__tab-badge {
32 | margin-left: 1ex;
33 | border-radius: 7px;
34 | font-size: 9px;
35 | line-height: 10px;
36 | color: #aaa;
37 | }
38 | .app-bar__tab-badge:empty {
39 | display: none;
40 | }
41 |
42 | .app-bar__prelude {
43 | border: solid #ddd;
44 | border-width: 0 1px 1px 0;
45 | }
46 | .app-bar__pick-component {
47 | width: 25px;
48 | height: 21px;
49 | vertical-align: top;
50 | padding: 0;
51 | margin: 0;
52 | border: 0;
53 | -webkit-mask: center / 16px no-repeat url("../../images/pick-component.svg");
54 | mask: center / 16px no-repeat url("../../images/pick-component.svg");
55 | background-color: #888;
56 | filter: hue-rotate(10, 10, 10);
57 | }
58 | .app-bar__pick-component:hover {
59 | background-color: #000;
60 | }
61 | .app-bar__pick-component.active {
62 | background-color: #4f96d5;
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui/components/appbar/AppBar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { AppPage, AppPageConfig } from "../../pages";
3 | import { Renderer } from "./Renderer";
4 | import { useInspectMode } from "../../utils/highlighting";
5 |
6 | const renderer = ;
7 | const AppBar = ({
8 | pages,
9 | page,
10 | setPage,
11 | }: {
12 | pages: Record;
13 | page: AppPage;
14 | setPage: (page: AppPage) => void;
15 | }) => {
16 | const { inspectMode, toggleInspect } = useInspectMode();
17 |
18 | return (
19 |
20 |
21 |
28 |
29 | {Object.values(pages).map(({ id, title, disabled, badge: Badge }) =>
30 | disabled ? (
31 |
32 | ) : (
33 |
setPage(id)}
37 | >
38 | {title}
39 | {Badge ? (
40 |
41 |
42 |
43 | ) : null}
44 |
45 | )
46 | )}
47 |
48 | {renderer}
49 |
50 | );
51 | };
52 |
53 | const AppbarMemo = React.memo(AppBar);
54 | AppbarMemo.displayName = "AppBar";
55 |
56 | export default AppbarMemo;
57 |
--------------------------------------------------------------------------------
/src/ui/components/appbar/Renderer.css:
--------------------------------------------------------------------------------
1 | .renderer-info {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | order: 1;
6 | gap: 4px;
7 | padding: 0 6px 0 0;
8 | border-bottom: 1px solid #ddd;
9 | font-size: 11px;
10 | color: #888;
11 | }
12 | .renderer-info::before {
13 | content: "";
14 | border-left: 1px solid #ddd;
15 | height: 12px;
16 | padding-right: 2px;
17 | }
18 |
19 | .renderer-info__type {
20 | background: #eee;
21 | text-transform: uppercase;
22 | font-size: 9px;
23 | margin: 0px -1px 0px -3px;
24 | line-height: 12px;
25 | padding: 2px 4px;
26 | border-radius: 3px;
27 | cursor: help;
28 | }
29 | .renderer-info__type[data-type="development"] {
30 | background-color: #d9ecd1;
31 | color: #88a06f;
32 | }
33 | .renderer-info__type[data-type="production"],
34 | .renderer-info__type[data-type="profiling"] {
35 | background: #eaeac7;
36 | color: #949c35;
37 | }
38 |
39 | .renderer-info__name {
40 | opacity: 0.85;
41 | line-height: 12px;
42 | min-width: 45px;
43 | max-width: 100px;
44 | white-space: nowrap;
45 | overflow: hidden;
46 | text-overflow: ellipsis;
47 | }
48 |
49 | .renderer-info__version {
50 | overflow: hidden;
51 | text-overflow: ellipsis;
52 | white-space: nowrap;
53 | max-width: 60px;
54 | }
55 |
--------------------------------------------------------------------------------
/src/ui/components/appbar/Renderer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useReactRenderers } from "../../utils/react-renderers";
3 | import { RendererBundleType } from "common-types";
4 |
5 | const fullyFunctional = "Fully functional";
6 | const partiallyFunctional =
7 | "Partially functional due to lack of some internals in this type of React bundle which are necessary for the full capturing of data";
8 | const bundleTypeInfo: Record<
9 | RendererBundleType,
10 | { abbr: string; description: string }
11 | > = {
12 | development: { abbr: "dev", description: fullyFunctional },
13 | production: {
14 | abbr: "prod",
15 | description: partiallyFunctional,
16 | },
17 | profiling: { abbr: "prof", description: partiallyFunctional },
18 | unknown: { abbr: "unknown", description: "" },
19 | };
20 |
21 | export function Renderer() {
22 | const { selected: currentRenderer } = useReactRenderers();
23 |
24 | if (!currentRenderer) {
25 | return null;
26 | }
27 |
28 | return (
29 |
33 |
37 | m.toLocaleUpperCase()
38 | )} React bundle\n\n${
39 | bundleTypeInfo[currentRenderer.bundleType].description
40 | }`}
41 | >
42 | {bundleTypeInfo[currentRenderer.bundleType].abbr}
43 |
44 | {currentRenderer.name}
45 |
46 | {currentRenderer.version}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/ui/components/common/ButtonToggle.css:
--------------------------------------------------------------------------------
1 | .button-toggle {
2 | padding: 6px;
3 | background: none;
4 | background-clip: padding-box;
5 | border: none;
6 | border-radius: 3px;
7 | cursor: pointer;
8 | line-height: 0;
9 | color: #aaa;
10 | transition: 0.2s;
11 | }
12 | .button-toggle.active {
13 | color: #555;
14 | background-color: #e8e8e8;
15 | }
16 | .button-toggle[disabled] {
17 | pointer-events: none;
18 | opacity: 0.5;
19 | }
20 | .button-toggle:not([disabled]):hover {
21 | color: #222;
22 | }
23 |
24 | .button-toggle svg {
25 | height: 16px;
26 | width: 16px;
27 | vertical-align: middle;
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/components/common/ButtonToggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface ButtonToggleProps {
4 | icon: JSX.Element;
5 | isActive?: boolean;
6 | isDisabled?: boolean;
7 | onChange: (fn: (state: boolean) => boolean) => void;
8 | tooltip: string;
9 | className?: string;
10 | }
11 |
12 | const ButtonToggle = ({
13 | icon,
14 | isActive = false,
15 | isDisabled = false,
16 | onChange,
17 | tooltip,
18 | className,
19 | }: ButtonToggleProps) => {
20 | const handleClick = React.useCallback(
21 | (event: React.MouseEvent) => {
22 | event.stopPropagation();
23 | onChange((prev: boolean) => !prev);
24 | },
25 | [onChange]
26 | );
27 |
28 | return (
29 |
39 | );
40 | };
41 |
42 | export default ButtonToggle;
43 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberHocNames.css:
--------------------------------------------------------------------------------
1 | .fiber-hoc-names {
2 | margin-left: 3px;
3 | font-size: 10px;
4 | line-height: 10px;
5 | color: white;
6 | }
7 | .fiber-hoc-name {
8 | padding: 2px 4px;
9 | border: 1px solid rgba(255, 255, 255, 0.65);
10 | border-radius: 3px;
11 | background-color: #d0eac6;
12 | background-clip: padding-box;
13 | color: #6c9c1e;
14 | }
15 | .fiber-hoc-name + .fiber-hoc-name {
16 | margin-left: 2px;
17 | }
18 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberHocNames.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface FiberHocNamesProps {
4 | names: string[] | null;
5 | }
6 |
7 | export default function FiberHocNames({ names }: FiberHocNamesProps) {
8 | if (!names || !names.length) {
9 | return null;
10 | }
11 |
12 | return (
13 |
14 | {names.map(name => (
15 |
20 | {name}
21 |
22 | ))}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberId.css:
--------------------------------------------------------------------------------
1 | .fiber-id {
2 | color: #a1a1a1;
3 | font-size: 10px;
4 | margin-left: 5px;
5 | }
6 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberId.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface FiberIdProps {
4 | id: number;
5 | }
6 |
7 | const FiberId = ({ id }: FiberIdProps) => {
8 | return #{id};
9 | };
10 |
11 | export default FiberId;
12 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberKey.css:
--------------------------------------------------------------------------------
1 | .fiber-key {
2 | margin-left: 7px;
3 | padding: 2px 5px 2px 2px;
4 | white-space: nowrap;
5 | border-radius: 0 4px 4px 0;
6 | border: 1px solid rgba(255, 255, 255, 0.65);
7 | background-color: #e8e8e8;
8 | background-clip: padding-box;
9 | color: #777;
10 | font-size: 10px;
11 | }
12 | .fiber-key::before {
13 | content: "";
14 | width: 6px;
15 | height: 17px;
16 | display: inline-block;
17 | background: no-repeat url("../../images/fiber-key-tail.svg");
18 | vertical-align: middle;
19 | box-sizing: border-box;
20 | margin: -4px 3px -3px -8px;
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberKey.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MessageFiber } from "../../types";
3 |
4 | const MAX_TEXT = 12;
5 |
6 | interface FiberKeyProps {
7 | fiber: MessageFiber;
8 | }
9 |
10 | const FiberKey = ({ fiber: { displayName, key } }: FiberKeyProps) => {
11 | const value = String(key);
12 |
13 | return (
14 | `}>
15 | {value.length > MAX_TEXT ? value.substr(0, MAX_TEXT) + "…" : value}
16 |
17 | );
18 | };
19 |
20 | export default FiberKey;
21 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberMaybeLeak.css:
--------------------------------------------------------------------------------
1 | .fiber-maybe-leak {
2 | display: inline-block;
3 | min-width: 16px;
4 | padding: 3px 4px 2px;
5 | border-radius: 3px;
6 | background-clip: padding-box;
7 | background-color: #f6d8d8;
8 | color: #a36c6c;
9 | text-transform: uppercase;
10 | font-size: 9px;
11 | line-height: 10px;
12 | text-align: center;
13 | }
14 | .fiber-info-header-prelude__content .fiber-maybe-leak {
15 | padding: 4px 4px 3px;
16 | }
17 |
--------------------------------------------------------------------------------
/src/ui/components/common/FiberMaybeLeak.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TrackingObjectTypeName } from "../../../common/constants";
3 | import { TrackingObjectType } from "common-types";
4 |
5 | interface FiberMaybeLeakProps {
6 | leaked: number;
7 | }
8 |
9 | const nothingLeaked: string[] = [];
10 | function listOfLeaks(leaked: number) {
11 | if (leaked === 0) {
12 | return nothingLeaked;
13 | }
14 |
15 | const result = [];
16 | let bitNum = 0;
17 |
18 | while (leaked >= 1 << bitNum) {
19 | if (leaked & (1 << bitNum)) {
20 | const title = TrackingObjectTypeName[bitNum as TrackingObjectType];
21 | result.push(`${title} (${title[0].toUpperCase()})`);
22 | }
23 |
24 | bitNum++;
25 | }
26 |
27 | return result;
28 | }
29 |
30 | function formatListOfLeaks(leaks: string[]) {
31 | return "\n- " + leaks.join("\n- ");
32 | }
33 |
34 | function codesOfLeaks(leaks: string[]) {
35 | return leaks.map(leak => leak[0]);
36 | }
37 |
38 | export default function FiberMaybeLeak({ leaked }: FiberMaybeLeakProps) {
39 | if (!leaked) {
40 | return null;
41 | }
42 |
43 | const leaks = listOfLeaks(leaked);
44 |
45 | return (
46 |
52 | Maybe mem leak ({codesOfLeaks(leaks)})
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/ui/components/common/SourceLoc.css:
--------------------------------------------------------------------------------
1 | .source-loc {
2 | padding-left: 12px;
3 | background-repeat: no-repeat;
4 | background-image: url("../../images/source-loc.svg");
5 | background-size: 10px;
6 | background-position: 0px center;
7 | cursor: default;
8 | }
9 | .source-loc_unresolved {
10 | background-image: none;
11 | }
12 | .source-loc:hover {
13 | color: #7caf46;
14 | }
15 | .source-loc_openable {
16 | text-decoration: underline;
17 | text-decoration-style: dotted;
18 | cursor: pointer;
19 | }
20 | .source-loc_openable:not(:hover) {
21 | text-decoration-color: #8886;
22 | }
23 | a.source-loc {
24 | color: unset;
25 | background-image: url("../../images/source-loc-open.svg");
26 | background-size: 9px;
27 | background-position: 1px center;
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/components/common/SourceLoc.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { LocType } from "../../types";
3 | import { useOpenFile } from "../../utils/open-file";
4 | import { useResolvedLocation } from "../../utils/source-locations";
5 |
6 | export function SourceLoc({
7 | loc,
8 | type,
9 | children,
10 | }: {
11 | loc: string | null | undefined;
12 | type?: LocType;
13 | children: React.ReactNode;
14 | }) {
15 | const { anchorAttrs } = useOpenFile();
16 |
17 | if (!loc) {
18 | return <>{children}>;
19 | }
20 |
21 | const attrs = anchorAttrs(loc, type);
22 |
23 | if (!attrs) {
24 | return (
25 |
26 | {children}
27 |
28 | );
29 | }
30 |
31 | return (
32 |
37 | {children}
38 |
39 | );
40 | }
41 |
42 | export function ResolveSourceLoc({
43 | loc,
44 | type,
45 | children,
46 | }: {
47 | loc: string | null | undefined;
48 | type?: LocType;
49 | children: React.ReactNode;
50 | }) {
51 | const resolvedLoc = useResolvedLocation(loc);
52 |
53 | if (!loc) {
54 | return <>{children}>;
55 | }
56 |
57 | if (!resolvedLoc) {
58 | return (
59 |
60 | {children}
61 |
62 | );
63 | }
64 |
65 | return (
66 |
67 | {children}
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/ui/components/details/CallStack.css:
--------------------------------------------------------------------------------
1 | .details-call-stack {
2 | font-size: 11px;
3 | color: #888;
4 | }
5 | .details-call-stack-list {
6 | flex-basis: 100%;
7 | margin: 0 0 0 14px;
8 | padding: 0;
9 | font-size: 11px;
10 | color: #444;
11 | }
12 | .details-call-stack-list li::marker {
13 | font-size: 10px;
14 | color: #888;
15 | }
16 | .details-call-stack-more,
17 | .details-call-stack-show-paths {
18 | cursor: pointer;
19 | padding: 2px 3px;
20 | border: 1px solid white;
21 | border-radius: 3px;
22 | background-color: #eee;
23 | font-size: 10px;
24 | color: #888;
25 | }
26 | .details-call-stack-more:hover,
27 | .details-call-stack-show-paths:hover {
28 | background-color: #ddd;
29 | }
30 |
--------------------------------------------------------------------------------
/src/ui/components/details/CallStack.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TransferCallTrace, TransferCallTracePoint } from "../../types";
3 | import { ResolveSourceLoc } from "../common/SourceLoc";
4 |
5 | export function CallTracePath({
6 | path,
7 | expanded = false,
8 | }: {
9 | path: TransferCallTracePoint[] | null | undefined;
10 | expanded?: boolean;
11 | }) {
12 | const [collapsed, setCollapsed] = React.useState(!expanded);
13 |
14 | if (!Array.isArray(path)) {
15 | return null;
16 | }
17 |
18 | const isFit = path.length === 2 && path[1].name.length < 12;
19 |
20 | if (collapsed && path.length > 1 && !isFit) {
21 | const first = path[0];
22 |
23 | return (
24 |
25 | {first.name}
26 | {" → "}
27 | setCollapsed(false)}
30 | >
31 | …{path.length - 1} more…
32 |
33 | {" → "}
34 |
35 | );
36 | }
37 |
38 | return (
39 |
40 | {path.map((entry, index) => (
41 |
42 | {entry.name}
43 | {" → "}
44 |
45 | ))}
46 |
47 | );
48 | }
49 |
50 | export function CallTraceList({
51 | traces,
52 | expanded,
53 | compat = true,
54 | }: {
55 | traces: TransferCallTrace[];
56 | expanded?: boolean;
57 | compat?: boolean;
58 | }) {
59 | const [collapsed, setCollapsed] = React.useState();
60 |
61 | if (compat && traces.length < 2) {
62 | return ;
63 | }
64 |
65 | if (collapsed === undefined ? !expanded : collapsed) {
66 | return (
67 |
68 | setCollapsed(false)}
71 | >
72 | …{traces.length} paths…
73 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
80 | {traces.map((trace, index) => (
81 | -
82 |
86 | useContext(…)
87 |
88 | ))}
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/ui/components/details/Details.css:
--------------------------------------------------------------------------------
1 | .details {
2 | grid-area: details;
3 | display: flex;
4 | flex-direction: column;
5 | overflow: hidden;
6 | border-left: 1px solid #ddd;
7 | transform: translateZ(0);
8 | }
9 |
10 | .details__header {
11 | position: relative;
12 | z-index: 10;
13 | padding: 6px 8px 4px;
14 | font-size: 12px;
15 | line-height: 14px;
16 | }
17 | .details__header_content-scrolled {
18 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
19 | transition: box-shadow 0.25s ease-in;
20 | }
21 |
22 | .details__content {
23 | flex: 1;
24 | overflow: auto;
25 | overscroll-behavior: contain;
26 | }
27 |
--------------------------------------------------------------------------------
/src/ui/components/details/Details.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import FiberInfo from "./info/FiberInfo";
3 | import { FiberInfoHeader } from "./FiberHeader";
4 | import { useFiber } from "../../utils/fiber-maps";
5 |
6 | interface DetailsProps {
7 | rootId: number;
8 | groupByParent: boolean;
9 | showUnmounted: boolean;
10 | showTimings: boolean;
11 | }
12 |
13 | const Details = ({
14 | rootId,
15 | groupByParent = false,
16 | showUnmounted = true,
17 | showTimings = false,
18 | }: DetailsProps) => {
19 | const [scrolled, setScrolled] = React.useState(false);
20 | const fiber = useFiber(rootId);
21 |
22 | if (fiber === undefined) {
23 | return (
24 |
25 |
Fiber with #{rootId} is not found
;
26 |
27 | );
28 | }
29 |
30 | return (
31 |
32 |
38 |
43 |
44 |
setScrolled((e.target as HTMLDivElement).scrollTop > 0)}
47 | >
48 |
54 |
55 |
56 | );
57 | };
58 |
59 | const DetailsMemo = React.memo(Details);
60 | DetailsMemo.displayName = "Details";
61 |
62 | export default DetailsMemo;
63 |
--------------------------------------------------------------------------------
/src/ui/components/details/EventChangesSummary.css:
--------------------------------------------------------------------------------
1 | .event-changes-summary {
2 | display: inline-block;
3 | padding: 1px 6px 1px 2px;
4 | border: 1px solid rgba(255, 255, 255, 0.65);
5 | border-radius: 3px;
6 | background-color: #f0f0f0;
7 | color: #999;
8 | cursor: pointer;
9 | user-select: none;
10 | }
11 | .event-changes-summary.expanded {
12 | position: relative;
13 | z-index: 2;
14 | border-color: #d0d0d0;
15 | border-bottom-color: transparent;
16 | background-color: #f7f7f7;
17 | border-bottom-left-radius: 0;
18 | border-bottom-right-radius: 0;
19 | }
20 | .event-changes-summary:hover {
21 | background: #ddd;
22 | }
23 | .event-changes-summary.expanded:hover {
24 | border-color: #ddd;
25 | }
26 | .event-changes-summary::before {
27 | content: "";
28 | display: inline-block;
29 | vertical-align: bottom;
30 | position: relative;
31 | top: -1px;
32 | width: 13px;
33 | height: 13px;
34 | background: no-repeat center;
35 | background-image: url("../../images/expander.svg");
36 | background-size: 13px;
37 | transform: rotate(-90deg);
38 | opacity: 0.35;
39 | transition: 0.2s;
40 | transition-property: opacity, transform;
41 | }
42 | .event-changes-summary.has-warnings::after {
43 | content: "";
44 | display: inline-block;
45 | vertical-align: middle;
46 | margin-top: -1px;
47 | margin-left: 2px;
48 | margin-right: -3px;
49 | width: 11px;
50 | height: 11px;
51 | background: url("../../images/warning-exc-sign.svg") no-repeat center #eab439;
52 | background-size: 7px;
53 | background-clip: content-box;
54 | border-radius: 3px;
55 | border: 1px solid rgba(255, 255, 255, 0.65);
56 | }
57 | .event-changes-summary:hover::before {
58 | opacity: 1;
59 | }
60 | .event-changes-summary.expanded::before {
61 | transform: none;
62 | }
63 | .event-changes-summary-reason {
64 | color: #555;
65 | margin-left: 1px;
66 | }
67 | .event-changes-summary-reason + .event-changes-summary-reason::before {
68 | content: ", ";
69 | color: #999;
70 | }
71 |
--------------------------------------------------------------------------------
/src/ui/components/details/EventChangesSummary.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FiberChanges } from "../../types";
3 |
4 | function getChangesSummary(changes: FiberChanges) {
5 | const { context, props, state } = changes;
6 | const reasons: string[] = [];
7 |
8 | if (props && props.length) {
9 | reasons.push("props");
10 | }
11 |
12 | if (context) {
13 | reasons.push("context");
14 | }
15 |
16 | if (state) {
17 | reasons.push("state");
18 | }
19 |
20 | return reasons.length > 0 ? reasons : null;
21 | }
22 |
23 | export function EventChangesSummary({
24 | changes = null,
25 | expanded = false,
26 | toggleExpanded,
27 | }: {
28 | changes: FiberChanges | null;
29 | expanded?: boolean;
30 | toggleExpanded?: () => void;
31 | }) {
32 | const changesSummary = changes !== null ? getChangesSummary(changes) : null;
33 |
34 | if (changesSummary === null) {
35 | return null;
36 | }
37 |
38 | return (
39 |
47 | {"± "}
48 | {changesSummary.map(reason => (
49 |
50 | {reason}
51 |
52 | ))}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/ui/components/details/Fiber.css:
--------------------------------------------------------------------------------
1 | .details-fiber__name {
2 | display: inline-block;
3 | color: #4f96d5;
4 | }
5 |
6 | .details-fiber__name_link {
7 | text-decoration: underline;
8 | text-decoration-color: #0279c955;
9 | cursor: pointer;
10 | }
11 | .details-fiber__name_link:hover {
12 | color: #0279c9;
13 | }
14 | .details-fiber__name_host-type {
15 | color: #8c629a;
16 | }
17 | .details-fiber__name_unmounted {
18 | color: #aaa;
19 | text-decoration: line-through;
20 | text-decoration-color: #888;
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/components/details/Fiber.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import FiberId from "../common/FiberId";
3 | import FiberKey from "../common/FiberKey";
4 | import { useFiber } from "../../utils/fiber-maps";
5 | import { useSelectionState } from "../../utils/selection";
6 | import { isHostType } from "../../utils/fiber";
7 |
8 | export const Fiber = ({
9 | fiberId,
10 | unmounted = false,
11 | }: {
12 | fiberId: number;
13 | unmounted?: boolean;
14 | }) => {
15 | const fiber = useFiber(fiberId);
16 | const { selected, select } = useSelectionState(fiberId);
17 |
18 | if (!fiber) {
19 | return null;
20 | }
21 |
22 | return (
23 |
24 | select(fiberId) : undefined}
34 | >
35 | {fiber.displayName}
36 |
37 |
38 | {fiber.key !== null && }
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/ui/components/details/FiberHeader.css:
--------------------------------------------------------------------------------
1 | .fiber-info-header-content {
2 | color: #444;
3 | font-size: 14px;
4 | line-height: 16px;
5 | }
6 | .fiber-info-header-content .source-loc {
7 | margin-left: 6px;
8 | font-size: 11px;
9 | }
10 | .fiber-info-header-content .source-loc:not(:hover) {
11 | color: #777;
12 | }
13 |
14 | .fiber-info-header-prelude {
15 | display: flex;
16 | flex-wrap: wrap-reverse;
17 | align-items: start;
18 | justify-content: flex-end;
19 | gap: 2px 8px;
20 | margin-top: -2px;
21 | margin-left: -3px;
22 | margin-right: -4px;
23 | margin-bottom: 2px;
24 | }
25 | .fiber-info-header-prelude__content {
26 | display: flex;
27 | flex-wrap: wrap;
28 | gap: 1px;
29 | flex: 1;
30 | white-space: nowrap;
31 | }
32 | .fiber-info-header-type-badge {
33 | display: inline-block;
34 | padding: 4px 4px 3px;
35 | border-radius: 3px;
36 | white-space: nowrap;
37 | overflow: hidden;
38 | text-overflow: ellipsis;
39 | font-size: 9px;
40 | line-height: 10px;
41 | text-transform: uppercase;
42 | background-color: #eee;
43 | color: #888;
44 | }
45 | .fiber-info-header-type-badge[data-type="type"] {
46 | background-color: #d9ecd1;
47 | color: #88a06f;
48 | }
49 |
50 | .fiber-info-header-prelude__buttons {
51 | display: flex;
52 | padding-left: 2px;
53 | }
54 | .fiber-info-header-prelude__button {
55 | vertical-align: middle;
56 | padding: 2px 2px;
57 | margin-top: -2px;
58 | margin-bottom: -2px;
59 | background: none;
60 | border: none;
61 | color: #666;
62 | }
63 | .fiber-info-header-prelude__button[disabled] {
64 | color: #ccc;
65 | }
66 | .fiber-info-header-prelude__button:not([disabled]):hover {
67 | cursor: pointer;
68 | color: #222;
69 | }
70 | .fiber-info-header-prelude__button.selected {
71 | background: #e8e8e8;
72 | border-radius: 3px;
73 | }
74 | .fiber-info-header-prelude__button svg {
75 | vertical-align: middle;
76 | height: 12px;
77 | margin: -3px 0 0;
78 | }
79 |
80 | .fiber-info-instance-iterator {
81 | color: #999;
82 | display: flex;
83 | align-items: center;
84 | }
85 | .fiber-info-instance-iterator__label {
86 | white-space: nowrap;
87 | margin-right: 6px;
88 | }
89 | .fiber-info-instance-iterator .fiber-info-header-prelude__buttons {
90 | border-left: 1px solid #ccc;
91 | border-right: 1px solid #ccc;
92 | padding-right: 2px;
93 | margin-right: 3px;
94 | }
95 | .fiber-info-instance-iterator .fiber-info-header-prelude__button svg {
96 | width: 14px;
97 | }
98 |
99 | .fiber-info-header-notes {
100 | margin-top: 1px;
101 | font-size: 10px;
102 | line-height: 12px;
103 | color: #bbb;
104 | }
105 | .fiber-info-header-notes .details-fiber-link__name {
106 | color: #999;
107 | text-decoration-color: #9995;
108 | }
109 | .fiber-info-header-notes .fiber-id {
110 | margin-left: 3px;
111 | }
112 |
--------------------------------------------------------------------------------
/src/ui/components/details/FiberLink.css:
--------------------------------------------------------------------------------
1 | .details-fiber-link {
2 | display: inline-block;
3 | }
4 | .details-fiber-link__name {
5 | text-decoration: underline;
6 | text-decoration-color: #0279c959;
7 | color: #0279c9;
8 | cursor: pointer;
9 | }
10 | .details-fiber-link__name:hover {
11 | color: #0398fc;
12 | }
13 |
--------------------------------------------------------------------------------
/src/ui/components/details/FiberLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectionState } from "../../utils/selection";
3 | import FiberId from "../common/FiberId";
4 |
5 | export function FiberLink({ id, name }: { id: number; name: React.ReactNode }) {
6 | const { selected, select } = useSelectionState(id);
7 |
8 | return (
9 |
10 | select(id) : undefined}
13 | >
14 | {name || "Unknown"}
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/Diff.css:
--------------------------------------------------------------------------------
1 | .diff-bracket {
2 | font-family: monospace;
3 | color: #aaa;
4 | }
5 |
6 | .diff-line {
7 | display: block;
8 | min-width: 100%;
9 | box-sizing: border-box;
10 | padding-left: 16px;
11 | font-size: 12px;
12 | line-height: 18px;
13 | color: #444;
14 | }
15 | .diff-line .key {
16 | color: #9c913b;
17 | }
18 | .diff-line.added .key {
19 | color: #359c42;
20 | }
21 | .diff-line.added .key::before {
22 | content: "+";
23 | margin-left: calc(-1ch - 4px);
24 | margin-right: 4px;
25 | font-family: monospace;
26 | opacity: 0.65;
27 | }
28 | .diff-line.removed {
29 | color: #ca6a6a;
30 | }
31 | .diff-line.removed .key::before {
32 | content: "-";
33 | margin-left: calc(-1ch - 4px);
34 | margin-right: 4px;
35 | font-family: monospace;
36 | opacity: 0.65;
37 | }
38 | .diff-line.removed .key {
39 | color: inherit;
40 | }
41 | .diff-line.removed code {
42 | background-color: #f3ecec;
43 | color: inherit;
44 | }
45 |
46 | .diff-value {
47 | padding: 2px 4px;
48 | border-radius: 4px;
49 | color: #619c5c;
50 | background-color: #ecf3ed;
51 | font-family: Consolas, monospace;
52 | }
53 | .diff-value.removed {
54 | background-color: #f3ecec;
55 | color: #ca6a6a;
56 | }
57 |
58 | .diff-rest {
59 | display: block;
60 | min-width: 100%;
61 | padding-left: 12px;
62 | font-size: 10px;
63 | color: #aaa;
64 | }
65 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/Diff.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TransferChangeDiff, ValueTransition } from "../../../types";
3 | import { DiffArray } from "./DiffArray";
4 | import { DiffObject } from "./DiffObject";
5 | import { DiffSimple } from "./DiffSimple";
6 | import { ShallowEqual } from "./ShallowEqual";
7 |
8 | export function Diff({
9 | diff,
10 | values,
11 | }: {
12 | diff?: TransferChangeDiff;
13 | values: ValueTransition;
14 | }) {
15 | return (
16 | <>
17 | {typeof diff === "object" ? (
18 | "keys" in diff ? (
19 |
20 | ) : (
21 |
22 | )
23 | ) : (
24 | "prev" in values &&
25 | )}
26 | {diff === false && }
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/DiffArray.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TransferArrayDiff, ValueTransition } from "../../../types";
3 | import { DiffSimple } from "./DiffSimple";
4 |
5 | export function DiffArray({
6 | diff,
7 | values,
8 | }: {
9 | diff: TransferArrayDiff;
10 | values: ValueTransition;
11 | }) {
12 | const restChanges =
13 | diff.eqLeft > 0 || diff.eqRight > 0
14 | ? `${diff.eqLeft > 0 ? `first ${diff.eqLeft}` : ""}${
15 | diff.eqLeft > 0 && diff.eqRight > 0 ? " and " : ""
16 | }${diff.eqRight > 0 ? `last ${diff.eqRight}` : ""}${
17 | diff.eqLeft + diff.eqRight === 1 ? " element is" : " elements are"
18 | } equal`
19 | : "";
20 |
21 | return (
22 | <>
23 |
24 | {diff.prevLength !== diff.nextLength && (
25 |
26 | {"length "}
27 |
30 |
31 | )}
32 | {restChanges && {restChanges}}
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/DiffObject.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { TransferObjectDiff } from "../../../types";
3 | import { DiffSimple } from "./DiffSimple";
4 |
5 | function plural(value: number, one: string, many: string) {
6 | return value === 1 ? `${value} ${one}` : `${value} ${many}`;
7 | }
8 |
9 | export function DiffObject({ diff }: { diff: TransferObjectDiff }) {
10 | const sampleSize = diff.sample.length;
11 | const restKeys = diff.keys - sampleSize;
12 | const restNotes =
13 | restKeys === 0
14 | ? false
15 | : diff.diffKeys <= sampleSize
16 | ? `… all the rest ${plural(
17 | restKeys,
18 | "entry has",
19 | "entries have"
20 | )} not changed`
21 | : diff.keys === diff.diffKeys
22 | ? `… all the rest ${plural(
23 | restKeys,
24 | "entry is",
25 | "entries are"
26 | )} also changed`
27 | : `… ${diff.diffKeys - sampleSize} of the rest ${plural(
28 | restKeys,
29 | "entry",
30 | "entries"
31 | )} ${diff.diffKeys - sampleSize === 1 ? "is" : "are"} also changed`;
32 |
33 | return (
34 | <>
35 | {"{"}
36 | {diff.sample.map(values => {
37 | const key = {`${values.name}: `};
38 |
39 | if ("prev" in values === false) {
40 | return (
41 |
42 | {key}
43 | {values.next}
44 |
45 | );
46 | }
47 |
48 | if ("next" in values === false) {
49 | return (
50 |
51 | {key}
52 | {values.prev}
53 |
54 | );
55 | }
56 |
57 | return (
58 |
59 | {key}
60 |
61 |
62 | );
63 | })}
64 | {restNotes && {restNotes}}
65 | {"}"}
66 | >
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/DiffSimple.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ValueTransition } from "../../../types";
3 |
4 | export function DiffSimple({ values }: { values: ValueTransition }) {
5 | return (
6 |
7 | {values.prev}
8 | →
9 | {values.next}
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/ShallowEqual.css:
--------------------------------------------------------------------------------
1 | .shallow-equal-badge {
2 | display: inline-block;
3 | padding: 2px 4px 2px 16px;
4 | border: 1px solid #ece3b0;
5 | border-radius: 2px;
6 | background: #fdfcde url("../../../images/warning.svg") no-repeat 4px center;
7 | background-size: 9px;
8 | font-size: 10px;
9 | line-height: 11px;
10 | color: #b39b20;
11 | }
12 |
--------------------------------------------------------------------------------
/src/ui/components/details/diff/ShallowEqual.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function ShallowEqual() {
4 | return (
5 |
9 | Shallow equal
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventList.css:
--------------------------------------------------------------------------------
1 | .fiber-event-list {
2 | flex: 1;
3 | position: relative;
4 | }
5 | .fiber-event-list_show-timings::before {
6 | content: "";
7 | position: absolute;
8 | top: 0;
9 | left: 44px;
10 | bottom: 0;
11 | z-index: 10;
12 | box-sizing: border-box;
13 | width: 46px;
14 | border-left: 1px solid #ddd;
15 | border-right: 1px solid #ddd;
16 | pointer-events: none;
17 | }
18 |
19 | .fiber-event-list__no-events {
20 | padding: 1em 2ex;
21 | text-align: center;
22 | color: #888;
23 | }
24 |
25 | .fiber-event-list__show-more {
26 | display: flex;
27 | gap: 0 2px;
28 | padding: 2px 6px 4px;
29 | }
30 | .fiber-event-list__show-more button {
31 | vertical-align: top;
32 | color: #444;
33 | min-height: 0;
34 | padding: 1px 8px 2px;
35 | margin: 0;
36 | background-color: #ffffff1a;
37 | border: 1px solid rgba(127, 127, 127, 0.4);
38 | border-radius: 3px;
39 | font-size: 10px;
40 | line-height: 12px;
41 | font-family: system-ui, Arial, sans-serif;
42 | cursor: pointer;
43 | }
44 | .fiber-event-list__show-more button:hover,
45 | .fiber-event-list__show-more button:active,
46 | .fiber-event-list__show-more button:focus {
47 | background-color: #dddddd4d;
48 | border-color: #aaa9;
49 | outline: none;
50 | }
51 | .fiber-event-list__show-more button:active {
52 | background-color: #83838340;
53 | border-color: #7f7f7f66;
54 | }
55 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventListCommitEvent.css:
--------------------------------------------------------------------------------
1 | .event-list-item[data-type="commit"] .event-list-item__main {
2 | display: block;
3 | font-size: 11px;
4 | line-height: 15px;
5 | }
6 |
7 | .event-list-item[data-type="commit"] .event-list-item__dots {
8 | --size: 7px;
9 | }
10 |
11 | .event-list-item[data-type="commit"] .event-list-item__dots-next {
12 | top: 21px;
13 | z-index: 1;
14 | }
15 |
16 | .event-list-item__commit-name {
17 | position: relative;
18 | display: inline-block;
19 | padding: 0px 4px;
20 | margin: 4px 0 4px -25px;
21 | font-size: 10px;
22 | color: #999;
23 | background: white;
24 | border-radius: 8px;
25 | border: 1px solid #e8e8e8;
26 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.65);
27 | }
28 | .event-list-item__commit-triggers {
29 | margin-left: 5px;
30 | color: #aaa;
31 | text-decoration: underline;
32 | cursor: pointer;
33 | }
34 | .event-list-item__commit-details {
35 | position: relative;
36 | margin: 2px 4px 4px -6px;
37 | font-size: 12px;
38 | }
39 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventListCommitEvent.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { CommitTrigger, SourceCommitEvent } from "../../../types";
3 | import { useCommit } from "../../../utils/fiber-maps";
4 | import EventListEntry from "./EventListEntry";
5 | import { Fiber } from "../Fiber";
6 | import { ResolveSourceLoc } from "../../common/SourceLoc";
7 |
8 | interface EventListCommitEventProps {
9 | commitId: number;
10 | event: SourceCommitEvent;
11 | showTimings: boolean;
12 | prevConjunction: boolean;
13 | nextConjunction: boolean;
14 | }
15 |
16 | const CommitTriggers = ({ triggers }: { triggers: CommitTrigger[] }) => {
17 | return (
18 |
19 | {triggers?.map((trigger, idx) => (
20 |
21 | {trigger.relatedFiberId &&
22 | trigger.relatedFiberId !== trigger.fiberId && (
23 | <>
24 |
25 | {" → "}
26 | >
27 | )}
28 |
29 |
30 | [{trigger.event ? `${trigger.type} ${trigger.event}` : trigger.type}
31 | ]{" "}
32 | {
33 |
34 | {trigger.kind}
35 |
36 | }
37 |
38 |
39 | ))}
40 |
41 | );
42 | };
43 |
44 | const EventListCommitEvent = ({
45 | commitId,
46 | event,
47 | showTimings,
48 | prevConjunction,
49 | nextConjunction,
50 | }: EventListCommitEventProps) => {
51 | const [expanded, setIsCollapsed] = React.useState(false);
52 | const commit = useCommit(commitId);
53 | const triggers = commit?.start.event.triggers;
54 | const triggerFiberCount =
55 | triggers?.reduce((ids, trigger) => ids.add(trigger.fiberId), new Set())
56 | .size || 0;
57 | const details = triggers && expanded && (
58 |
59 | );
60 |
61 | return (
62 |
70 | Commit #{commitId}
71 | {false && triggers && (
72 | setIsCollapsed(expanded => !expanded)}
77 | >
78 | {triggerFiberCount}
79 | {triggerFiberCount > 1 ? " triggers" : " trigger"}
80 |
81 | )}
82 |
83 | );
84 | };
85 |
86 | export default EventListCommitEvent;
87 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventListFiberEvent.css:
--------------------------------------------------------------------------------
1 | .event-list-item[data-type="fiber"] .event-list-item__content {
2 | min-height: 23px;
3 | }
4 | .event-list-item[data-type="fiber"] .event-list-item__main {
5 | gap: 1px 8px;
6 | padding: 0 4px 2px 0;
7 | font-size: 12px;
8 | line-height: 15px;
9 | align-items: center;
10 | }
11 |
12 | .event-list-item[data-type="fiber"] .details-fiber {
13 | padding: 4px 0 0;
14 | }
15 | .event-list-item[data-type="fiber"] .details-fiber__name_selected {
16 | padding: 2px 8px 2px 25px;
17 | margin: -3px -2px -1px -26px;
18 | border: 1px solid rgba(255, 255, 255, 0.65);
19 | border-radius: 10px;
20 | background-color: var(--selected-fiber-bg-color, #eee);
21 | background-clip: padding-box;
22 | color: var(--selected-fiber-color, #888);
23 | }
24 | .event-list-item:not(.event-list-item_selected):not(:hover) .details-fiber {
25 | opacity: 0.8;
26 | }
27 |
28 | .event-list-item .event-changes-summary {
29 | margin-top: 2px;
30 | }
31 | .event-list-item .event-changes-summary.expanded {
32 | margin-top: 1px;
33 | margin-bottom: -4px;
34 | padding-top: 2px;
35 | padding-bottom: 5px;
36 | }
37 | .event-list-item:not(.event-list-item_selected):not(:hover)
38 | .event-changes-summary:not(:hover):not(.expanded) {
39 | opacity: 0.6;
40 | }
41 |
42 | .event-list-item .special-reason {
43 | margin-top: 2px;
44 | background-color: #f8e6ff;
45 | padding: 2px 4px 2px 4px;
46 | font-size: 10px;
47 | border-radius: 3px;
48 | line-height: 10px;
49 | border: 1px solid #dec6e7;
50 | }
51 | .event-list-item .special-reason_bailout {
52 | background-color: #dfedf9;
53 | color: #768591;
54 | border-color: #c8d7e3;
55 | }
56 | .event-list-item .special-reason .source-loc:hover {
57 | color: #9366a5;
58 | }
59 | .event-list-item:not(.event-list-item_selected):not(:hover) .special-reason {
60 | opacity: 0.65;
61 | }
62 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventRenderReasons.css:
--------------------------------------------------------------------------------
1 | .event-render-reasons {
2 | position: relative;
3 | padding: 1px 4px 4px 0;
4 | margin-left: -6px;
5 | }
6 |
7 | .event-render-reasons__list {
8 | position: relative;
9 | z-index: 0;
10 | border: 1px solid #d0d0d0;
11 | border-radius: 3px;
12 | background: #f7f7f7;
13 | font-size: 12px;
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventRenderReasons.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FiberEvent } from "../../../types";
3 | import {
4 | ContextChange,
5 | PropChange,
6 | StateChange,
7 | } from "./EventRenderReasonsItem";
8 |
9 | interface EventRenderReasonsProps {
10 | fiberId: number;
11 | changes: FiberEvent["changes"];
12 | nextConjunction: boolean;
13 | }
14 |
15 | const EventRenderReasons = ({
16 | fiberId,
17 | changes,
18 | nextConjunction,
19 | }: EventRenderReasonsProps) => {
20 | if (!changes) {
21 | return <>Unknown changes>;
22 | }
23 |
24 | const warn = changes.warnings || new Set();
25 |
26 | return (
27 |
33 |
34 | {changes.props?.map((entry, index) => (
35 |
36 | ))}
37 | {changes.context?.map((entry, index) => (
38 |
39 | ))}
40 | {changes.state?.map((entry, index) => (
41 |
42 | ))}
43 |
44 |
45 | );
46 | };
47 |
48 | export default EventRenderReasons;
49 |
--------------------------------------------------------------------------------
/src/ui/components/details/event-list/EventRenderReasonsItem.css:
--------------------------------------------------------------------------------
1 | .event-render-reason:not(:last-child) {
2 | border-bottom: 1px solid #e8e8e8;
3 | }
4 |
5 | .event-render-reason {
6 | display: flex;
7 | flex-wrap: wrap;
8 | align-items: baseline;
9 | gap: 3px 4px;
10 | padding: 4px;
11 | line-height: 16px;
12 | color: #444;
13 | }
14 |
15 | .event-render-reason__context-name {
16 | display: inline-block;
17 | color: #0279c9;
18 | }
19 |
20 | .event-render-reason__type-badge {
21 | display: inline-block;
22 | align-self: flex-start;
23 | margin-left: -8px;
24 | padding: 2px 4px;
25 | border-radius: 3px;
26 | font-size: 9px;
27 | line-height: 10px;
28 | text-transform: uppercase;
29 | border: 1px solid #cecbac;
30 | background-color: #f5f3de;
31 | color: #a09f6e;
32 | }
33 | .event-render-reason__type-badge_has-warning::after {
34 | content: "";
35 | display: inline-block;
36 | vertical-align: middle;
37 | margin-top: -2px;
38 | margin-left: 1px;
39 | margin-right: -2px;
40 | margin-bottom: -1px;
41 | width: 10px;
42 | height: 10px;
43 | background: url("../../../images/warning-exc-sign.svg") no-repeat center
44 | #eab439;
45 | background-size: 6px;
46 | background-clip: content-box;
47 | border-radius: 3px;
48 | border: 1px solid rgba(255, 255, 255, 0.65);
49 | }
50 |
51 | .event-render-reason > .fiber-id {
52 | margin-left: 1px;
53 | }
54 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/ChangesMatrix.css:
--------------------------------------------------------------------------------
1 | .changes-matrix {
2 | border-collapse: collapse;
3 | }
4 | .changes-matrix__main-header {
5 | padding: 2px 4px;
6 | text-align: left;
7 | vertical-align: bottom;
8 | white-space: nowrap;
9 | font-weight: normal;
10 | font-size: 11px;
11 | color: #666;
12 | }
13 | .changes-matrix__value-header {
14 | padding: 6px 0 0;
15 | min-width: 17px;
16 | writing-mode: vertical-lr;
17 | vertical-align: bottom;
18 | text-align: left;
19 | font-weight: normal;
20 | }
21 | .changes-matrix__value-header-text-wrapper {
22 | transform: translate(calc(100% + 1px), 100%) rotate(220deg);
23 | transform-origin: left top;
24 | }
25 | .changes-matrix__value-header-text {
26 | display: block;
27 | overflow: hidden;
28 | max-height: 85px;
29 | text-overflow: ellipsis;
30 | padding-left: 1px;
31 | padding-bottom: 12px;
32 | margin-bottom: -12px;
33 | border-left: 1px solid #ddd;
34 | white-space: nowrap;
35 | font-size: 11px;
36 | color: #666;
37 | text-align: left;
38 | }
39 | .changes-matrix__header-spacer {
40 | width: 100%;
41 | min-width: 50px;
42 | }
43 |
44 | .changes-matrix td {
45 | border: 1px solid #ddd;
46 | border-right: none;
47 | padding: 2px 4px;
48 | text-align: center;
49 | vertical-align: middle;
50 | }
51 | .changes-matrix .changes-matrix__row td:first-child {
52 | text-align: left;
53 | border-left: none;
54 | white-space: nowrap;
55 | }
56 | .changes-matrix .event-changes-summary {
57 | pointer-events: none;
58 | }
59 |
60 | .changes-matrix td.shallow-equal::before {
61 | content: "SE";
62 | padding: 3px 1px 2px;
63 | font-size: 7px;
64 | background: #fdfcde;
65 | border: 1px solid #ece3b0;
66 | border-radius: 7px;
67 | color: #b39b20;
68 | line-height: 1;
69 | }
70 | .changes-matrix td.has-diff::before {
71 | content: "±";
72 | display: inline-block;
73 | width: 14px;
74 | height: 14px;
75 | background-color: #e8e8e8;
76 | border-radius: 7px;
77 | color: #666;
78 | font-size: 10px;
79 | text-align: center;
80 | }
81 | .changes-matrix td.no-diff {
82 | min-width: 14px;
83 | }
84 | .changes-matrix td.no-diff::before {
85 | content: "–";
86 | color: #ccc;
87 | line-height: 15px;
88 | }
89 |
90 | .changes-matrix__row:not(.changes-matrix__row_no-details):hover {
91 | background-color: #f0f0f0;
92 | cursor: pointer;
93 | }
94 | .changes-matrix__row-details td {
95 | padding: 3px 6px 9px 15px;
96 | border-left: none;
97 | text-align: left;
98 | }
99 | .changes-matrix__diff-line {
100 | display: flex;
101 | flex-wrap: wrap;
102 | gap: 4px;
103 | }
104 | .changes-matrix__diff-line + .changes-matrix__diff-line {
105 | margin-top: 5px;
106 | }
107 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfo.css:
--------------------------------------------------------------------------------
1 | .fiber-info {
2 | padding: 6px 8px 6px;
3 | font-size: 12px;
4 | line-height: 14px;
5 | }
6 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSection.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section {
2 | margin: 0 0 0 8px;
3 | }
4 | .fiber-info-section:first-child {
5 | margin-top: -6px;
6 | }
7 | .fiber-info-section_no-data {
8 | pointer-events: none;
9 | }
10 | .fiber-info-section_expanded:not(:last-child)::after {
11 | content: "";
12 | display: block;
13 | height: 10px;
14 | }
15 |
16 | .fiber-info-section__header {
17 | position: sticky;
18 | top: 0;
19 | z-index: 5;
20 | padding: 4px 8px 4px 6px;
21 | margin-left: -16px;
22 | margin-right: -8px;
23 | text-transform: uppercase;
24 | line-height: 12px;
25 | font-size: 10px;
26 | color: #999;
27 | background: rgba(255, 255, 255, 0.9);
28 | backdrop-filter: blur(2px);
29 | }
30 | .fiber-info-section__header:hover {
31 | color: #555;
32 | cursor: pointer;
33 | }
34 | .fiber-info-section__header::before {
35 | content: "";
36 | display: inline-block;
37 | vertical-align: bottom;
38 | position: relative;
39 | width: 13px;
40 | height: 13px;
41 | margin-right: 1px;
42 | background: no-repeat left center;
43 | background-image: url("../../../images/expander.svg");
44 | background-size: 13px;
45 | transform: rotate(-90deg);
46 | opacity: 0.35;
47 | transition: 0.2s;
48 | transition-property: transform;
49 | }
50 | .fiber-info-section__header:hover::before {
51 | opacity: 1;
52 | }
53 | .fiber-info-section_expanded > .fiber-info-section__header {
54 | color: #555;
55 | }
56 | .fiber-info-section_expanded > .fiber-info-section__header::before {
57 | transform: rotate(0deg);
58 | }
59 | .fiber-info-section_no-data > .fiber-info-section__header::before {
60 | visibility: hidden;
61 | }
62 |
63 | .fiber-info-section__header-no-data {
64 | color: #bbb;
65 | }
66 | .fiber-info-section__header-no-data::before {
67 | content: " – ";
68 | }
69 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSection.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSectionStateContext } from "./FiberInfo";
3 |
4 | interface IFiberInfoSection {
5 | id: string;
6 | header: string;
7 | emptyText?: string;
8 | expandedOpts?: JSX.Element | JSX.Element[] | string | null;
9 | children?: React.ReactNode;
10 | }
11 |
12 | export function FiberInfoSection({
13 | id,
14 | header,
15 | emptyText,
16 | expandedOpts,
17 | children,
18 | }: IFiberInfoSection) {
19 | const { get: getSectionState, toggle: toggleSectionState } =
20 | useSectionStateContext();
21 | const expanded = getSectionState(id) && Boolean(children);
22 |
23 | return (
24 |
31 |
toggleSectionState(id)}
34 | >
35 | {header}
36 | {!children ? (
37 |
38 | {emptyText || "no data"}
39 |
40 | ) : (
41 | ""
42 | )}
43 | {expanded ? expandedOpts : null}
44 |
45 | {expanded && children}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionConsumers.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section-consumers-type-group {
2 | margin-left: 0px;
3 | }
4 | .fiber-info-section-consumers-type-group__content {
5 | margin: 2px 0 0 26px;
6 | line-height: 16px;
7 | }
8 | .fiber-info-section-consumers-single-instance {
9 | margin-left: 14px;
10 | }
11 | .fiber-info-section-consumers-type-group:not(:first-child),
12 | .fiber-info-section-consumers-single-instance:not(:first-child) {
13 | margin-top: 4px;
14 | }
15 |
16 | .fiber-info-section-consumers-single-instance_unmounted
17 | .details-fiber-link__name {
18 | color: #aaa;
19 | text-decoration: line-through;
20 | text-decoration-color: #888;
21 | }
22 |
23 | .fiber-info-section-consumers-type-group__header {
24 | color: #666;
25 | cursor: pointer;
26 | }
27 | .fiber-info-section-consumers-type-group__header:hover {
28 | color: #222;
29 | }
30 | .fiber-info-section-consumers-type-group__header::before {
31 | content: "";
32 | display: inline-block;
33 | vertical-align: bottom;
34 | position: relative;
35 | width: 13px;
36 | height: 13px;
37 | margin-right: 1px;
38 | background: no-repeat left center;
39 | background-image: url("../../../images/expander.svg");
40 | background-size: 13px;
41 | transform: rotate(-90deg);
42 | opacity: 0.35;
43 | transition: 0.2s;
44 | transition-property: transform;
45 | }
46 | .fiber-info-section-consumers-type-group__header:hover::before {
47 | opacity: 1;
48 | }
49 | .fiber-info-section-consumers-type-group__header_expanded::before {
50 | transform: rotate(0deg);
51 | }
52 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionContexts.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section-memo-contexts {
2 | padding-left: 4px;
3 | }
4 | .fiber-info-fiber-context__reads {
5 | margin-left: 8px;
6 | }
7 | .fiber-info-fiber-context__no-provider {
8 | color: #a33;
9 | }
10 | .fiber-info-fiber-context__no-provider::before {
11 | content: " – ";
12 | color: #ccc;
13 | }
14 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionContexts.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | MessageFiber,
4 | TransferFiberContext,
5 | TransferCallTrace,
6 | } from "../../../types";
7 | import { CallTraceList } from "../CallStack";
8 | import { FiberLink } from "../FiberLink";
9 | import { FiberInfoSection } from "./FiberInfoSection";
10 |
11 | export function FiberInfoSectionContexts({ fiber }: { fiber: MessageFiber }) {
12 | const { typeDef } = fiber;
13 |
14 | if (!Array.isArray(typeDef.contexts)) {
15 | return null;
16 | }
17 |
18 | const contextReadMap = typeDef.hooks.reduce((map, hook) => {
19 | if (hook.context) {
20 | const traces = map.get(hook.context);
21 | if (traces === undefined) {
22 | map.set(hook.context, [hook.trace]);
23 | } else {
24 | traces.push(hook.trace);
25 | }
26 | }
27 | return map;
28 | }, new Map());
29 |
30 | return (
31 |
36 |
37 | {typeDef.contexts.map((context, index) => {
38 | const traces = contextReadMap.get(context);
39 |
40 | return (
41 |
42 | {context.providerId !== undefined ? (
43 |
44 | ) : (
45 | <>
46 | {context.name}{" "}
47 |
48 | No provider found
49 |
50 | >
51 | )}
52 | {traces && (
53 |
54 |
55 |
56 | )}
57 |
58 | );
59 | })}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionEvents.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section-events {
2 | margin: 0 -8px 0 -16px;
3 | }
4 |
5 | .fiber-info-section-events__subtree-toggle {
6 | position: relative;
7 | top: -1px;
8 | align-items: baseline;
9 | padding-top: 0px;
10 | padding-bottom: 2px;
11 | padding-left: 4px;
12 | margin: -2px 0 -3px 4px;
13 | color: #888;
14 | font-size: 10px;
15 | line-height: 14px;
16 | border-radius: 8px;
17 | }
18 | .fiber-info-section-events__subtree-toggle:not(.active):hover {
19 | background-color: #f0f0f0;
20 | }
21 | .fiber-info-section-events__subtree-toggle.active:hover {
22 | background-color: #dedede;
23 | }
24 | .fiber-info-section-events__subtree-toggle svg {
25 | width: 12px;
26 | height: 12px;
27 | }
28 | .fiber-info-section-events__subtree-toggle::after {
29 | content: "include subtree";
30 | position: relative;
31 | padding: 0 0 0 4px;
32 | top: 1px;
33 | }
34 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionEvents.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MessageFiber } from "../../../types";
3 | import { useEventLog } from "../../../utils/events";
4 | import ButtonToggle from "../../common/ButtonToggle";
5 | import { SubtreeToggle } from "../../common/icons";
6 | import EventList from "../event-list/EventList";
7 | import { FiberInfoSection } from "./FiberInfoSection";
8 |
9 | export function FiberInfoSectionEvents({
10 | fiber,
11 | groupByParent,
12 | showUnmounted,
13 | showTimings,
14 | }: {
15 | fiber: MessageFiber;
16 | groupByParent: boolean;
17 | showUnmounted: boolean;
18 | showTimings: boolean;
19 | }) {
20 | const [showSubtreeEvents, setShowSubtreeEvents] = React.useState(false);
21 | const events = useEventLog(
22 | fiber.id,
23 | groupByParent,
24 | showUnmounted,
25 | showSubtreeEvents
26 | );
27 |
28 | return (
29 |
45 | }
46 | >
47 | {events.length === 0 ? undefined : (
48 |
49 |
61 |
62 | )}
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionHooks.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section-hooks-content {
2 | margin: 0;
3 | padding: 0px 0px 0px 8px;
4 | }
5 |
6 | .fiber-info-section-hooks-map {
7 | display: flex;
8 | flex-wrap: wrap;
9 | gap: 3px 2px;
10 | margin: 4px 4px 8px;
11 | font-size: 11px;
12 | }
13 | .fiber-info-section-hooks-map-button {
14 | padding: 2px 8px;
15 | background: #f4f4f4;
16 | border-radius: 10px;
17 | cursor: pointer;
18 | transition: 0.2s background-color;
19 | }
20 | .fiber-info-section-hooks-map-button:not(.selected):hover {
21 | background-color: #e4e4e4;
22 | }
23 | .fiber-info-section-hooks-map-button.selected {
24 | background-color: #dde8f4;
25 | cursor: default;
26 | }
27 | .fiber-info-section-hooks-map-button .count {
28 | color: #888;
29 | padding-left: 4px;
30 | font-size: 10px;
31 | }
32 |
33 | .fiber-info-section-hooks-leaf {
34 | margin: 2px 0 4px;
35 | }
36 | .fiber-info-section-hooks-leaf__custom-hook {
37 | color: #888;
38 | }
39 | .fiber-info-section-hooks-leaf__children {
40 | padding-left: 20px;
41 | }
42 | .fiber-info-section-hooks-leaf__context {
43 | color: #888;
44 | padding-left: 1ex;
45 | }
46 | .fiber-info-section-hooks-leaf__hook-index {
47 | color: #888;
48 | font-size: 10px;
49 | padding-left: 1ex;
50 | }
51 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionLeakedHooks.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MessageFiber } from "../../../types";
3 | import { useFiber } from "../../../utils/fiber-maps";
4 | import { ResolveSourceLoc } from "../../common/SourceLoc";
5 | import { CallTracePath } from "../CallStack";
6 | import { FiberInfoSection } from "./FiberInfoSection";
7 |
8 | export function FiberInfoSectionLeakedHooks({
9 | fiber,
10 | }: {
11 | fiber: MessageFiber;
12 | }) {
13 | const { typeDef, leakedHooks } = useFiber(fiber.id) || {};
14 |
15 | if (!leakedHooks || !leakedHooks.length) {
16 | return null;
17 | }
18 |
19 | return (
20 |
24 |
25 | {leakedHooks &&
26 | leakedHooks.map(hookIdx => {
27 | const hook = typeDef?.hooks[hookIdx];
28 |
29 | return (
30 | hook && (
31 | -
32 |
37 |
38 | {hook.name}(…)
39 | {" "}
40 |
41 | )
42 | );
43 | })}
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionMemoHooks.css:
--------------------------------------------------------------------------------
1 | .fiber-info-section-memo-content {
2 | margin: 0;
3 | padding-left: 16px;
4 | }
5 | .fiber-info-section-memo-content li:not(:last-child) {
6 | margin-bottom: 6px;
7 | }
8 | .fiber-info-section-memo-content li::marker {
9 | font-size: 10px;
10 | color: #888;
11 | }
12 |
13 | .fiber-info-section-memo-content__recompute-stat {
14 | display: block;
15 | color: #b58a3d;
16 | }
17 |
18 | .fiber-info-section-memo-content .event-changes-summary {
19 | padding-left: 6px;
20 | }
21 | .fiber-info-section-memo-content .event-changes-summary::before {
22 | display: none;
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/components/details/info/FiberInfoSectionProps.css:
--------------------------------------------------------------------------------
1 | .props-update-reaction_update,
2 | .props-update-reaction_bailout {
3 | padding: 1px 5px;
4 | margin: -1px -3px;
5 | border-radius: 8px;
6 | border: 1px solid rgba(255, 255, 255, 0.65);
7 | vertical-align: middle;
8 | line-height: 16px;
9 | }
10 |
11 | .props-update-reaction_update {
12 | background-color: #f0f0d5;
13 | color: #898927;
14 | }
15 | .props-update-reaction_bailout {
16 | background-color: #d9e8f3;
17 | color: #597d97;
18 | }
19 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/ButtonExpand.css:
--------------------------------------------------------------------------------
1 | .button-expand {
2 | padding: 0;
3 | margin: 0 2px 0 -15px;
4 | border: none;
5 | background: none;
6 | font-size: 0;
7 | vertical-align: middle;
8 | cursor: pointer;
9 | }
10 |
11 | .button-expand::before {
12 | content: "";
13 | display: inline-block;
14 | width: 13px;
15 | height: 13px;
16 | color: #999;
17 | background-image: url("./ButtonExpand.svg");
18 | background-position: center;
19 | background-size: 13px;
20 | vertical-align: middle;
21 | opacity: 0.35;
22 | transform: rotate(-90deg);
23 | transition: 0.2s;
24 | transition-property: opacity, transform;
25 | }
26 |
27 | .button-expand:hover::before {
28 | opacity: 1;
29 | }
30 |
31 | .button-expand.expanded::before {
32 | transform: rotate(0);
33 | }
34 |
35 | .button-expand.disabled {
36 | visibility: hidden;
37 | }
38 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/ButtonExpand.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/ButtonExpand.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface ButtonExpandProps {
4 | expanded: boolean;
5 | setExpanded?: (value: boolean) => void;
6 | }
7 |
8 | const ButtonExpand = ({ expanded, setExpanded }: ButtonExpandProps) => {
9 | const collapsedCls = expanded ? "expanded" : "";
10 | const disabledCls = setExpanded ? "" : "disabled";
11 | const handleClick =
12 | setExpanded &&
13 | ((event: React.MouseEvent) => {
14 | event.stopPropagation();
15 | setExpanded(!expanded);
16 | });
17 |
18 | return (
19 |
24 | );
25 | };
26 |
27 | export default ButtonExpand;
28 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/ScrollSelectedIntoViewIfNeeded.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectedId } from "../../utils/selection";
3 | import { useHighlightedId } from "../../utils/highlighting";
4 | import { getBoundingRect, getOverflowParent } from "../../utils/layout";
5 | import { useTreeViewSettingsContext } from "./contexts";
6 |
7 | export const ScrollFiberIntoViewIfNeeded = () => {
8 | const { selectedId } = useSelectedId();
9 | const { highlightedId } = useHighlightedId();
10 | const { getFiberElement } = useTreeViewSettingsContext();
11 | const { groupByParent, showUnmounted } = useTreeViewSettingsContext();
12 |
13 | React.useEffect(() => {
14 | const id = highlightedId || selectedId;
15 | const element =
16 | id !== null ? getFiberElement(id) || null : null;
17 |
18 | if (element !== null) {
19 | const viewportEl = getOverflowParent(element);
20 | const elementRect = getBoundingRect(element, viewportEl);
21 | const scrollMarginTop =
22 | parseInt(
23 | getComputedStyle(element).getPropertyValue("scroll-margin-top"),
24 | 10
25 | ) || 0;
26 | const scrollMarginLeft =
27 | parseInt(
28 | getComputedStyle(element).getPropertyValue("scroll-margin-left"),
29 | 10
30 | ) || 0;
31 |
32 | const { scrollTop, scrollLeft, clientWidth, clientHeight } =
33 | viewportEl as HTMLElement;
34 | const viewportTop = scrollTop + scrollMarginTop;
35 | const viewportLeft = scrollLeft + scrollMarginLeft;
36 | const viewportRight = scrollLeft + clientWidth;
37 | const viewportBottom = scrollTop + clientHeight;
38 | const elementTop = scrollTop + elementRect.top;
39 | const elementLeft = scrollLeft + elementRect.left;
40 | const elementRight = elementLeft + elementRect.width;
41 | // const elementBottom = elementTop + elementRect.height;
42 | let scrollToTop = scrollTop;
43 | let scrollToLeft = scrollLeft;
44 |
45 | if (elementTop < viewportTop || elementTop > viewportBottom) {
46 | scrollToTop = elementTop - scrollMarginTop;
47 | }
48 |
49 | if (elementLeft < viewportLeft) {
50 | scrollToLeft = elementLeft - scrollMarginLeft;
51 | } else if (elementRight > viewportRight) {
52 | scrollToLeft =
53 | Math.max(elementLeft, scrollLeft - (elementRight - viewportRight)) -
54 | scrollMarginLeft;
55 | }
56 |
57 | viewportEl?.scrollTo(scrollToLeft, scrollToTop);
58 | }
59 | }, [selectedId, highlightedId, groupByParent, showUnmounted]);
60 |
61 | return null;
62 | };
63 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/Tree.css:
--------------------------------------------------------------------------------
1 | .fiber-tree {
2 | grid-area: fiber-tree;
3 | position: relative;
4 | }
5 | .fiber-tree.timings {
6 | --scroll-margin-left: 90px;
7 | }
8 | .fiber-tree.timings::before {
9 | content: "";
10 | position: absolute;
11 | top: 0;
12 | left: 44px;
13 | bottom: 0;
14 | z-index: 10;
15 | box-sizing: border-box;
16 | width: 46px;
17 | border-left: 1px solid #ddd;
18 | border-right: 1px solid #ddd;
19 | pointer-events: none;
20 | }
21 |
22 | .fiber-tree__scroll-area {
23 | position: absolute;
24 | top: 0;
25 | left: 0;
26 | right: 0;
27 | bottom: 0;
28 | overflow: auto;
29 | scroll-behavior: smooth;
30 | overscroll-behavior: contain;
31 | }
32 |
33 | .fiber-tree__content {
34 | position: absolute;
35 | min-width: 100%;
36 | transform: translateZ(0);
37 | }
38 | .fiber-tree__no-children {
39 | padding: 4px 20px;
40 | font-size: 11px;
41 | color: #aaa;
42 | user-select: none;
43 | }
44 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/Tree.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useFiberChildren } from "../../utils/fiber-maps";
3 | import { ScrollFiberIntoViewIfNeeded } from "./ScrollSelectedIntoViewIfNeeded";
4 | import TreeLeaf from "./TreeLeaf";
5 | import { TreeViewSettings, TreeViewSettingsContext } from "./contexts";
6 |
7 | const Tree = ({
8 | rootId = 0,
9 | groupByParent = false,
10 | showUnmounted = true,
11 | showTimings = false,
12 | }: {
13 | rootId: number;
14 | groupByParent?: boolean;
15 | showUnmounted?: boolean;
16 | showTimings?: boolean;
17 | }) => {
18 | const children = useFiberChildren(rootId, groupByParent, showUnmounted);
19 | const viewSettings = React.useMemo(() => {
20 | const fiberElementById = new Map();
21 |
22 | return {
23 | setFiberElement: (id, element) =>
24 | element
25 | ? fiberElementById.set(id, element)
26 | : fiberElementById.delete(id),
27 | getFiberElement: id => fiberElementById.get(id) || null,
28 | groupByParent,
29 | showUnmounted,
30 | showTimings,
31 | };
32 | }, [groupByParent, showUnmounted, showTimings]);
33 |
34 | return (
35 |
36 |
37 |
38 |
39 | {rootId !== 0 && children.length === 0 ? (
40 | No children yet
41 | ) : (
42 | children.map(childId => (
43 |
48 | ))
49 | )}
50 |
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | const TreeMemo = React.memo(Tree);
59 | TreeMemo.displayName = "Tree";
60 |
61 | export default TreeMemo;
62 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeHeader.css:
--------------------------------------------------------------------------------
1 | .fiber-tree-header {
2 | background: white;
3 | }
4 | .fiber-tree-header__path {
5 | max-height: 50px;
6 | overflow: auto;
7 | padding: 3px 6px;
8 | background-color: #f6f6f6;
9 | border-bottom: 1px solid #ddd;
10 | font-size: 11px;
11 | line-height: 15px;
12 | color: #888;
13 | }
14 | .fiber-tree-header__path::before {
15 | content: "Pinned path: ";
16 | }
17 | .fiber-tree-header-fiber-link {
18 | display: inline-block;
19 | }
20 | .fiber-tree-header-fiber-link:not(:last-child)::after {
21 | content: "\a0→\a0";
22 | color: #aaa;
23 | }
24 | .fiber-tree-header-fiber-link__name {
25 | text-decoration: underline;
26 | text-decoration-color: #8885;
27 | color: #777;
28 | cursor: pointer;
29 | }
30 | .fiber-tree-header-fiber-link__name:hover {
31 | color: #555;
32 | }
33 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeHeader.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MessageFiber } from "../../types";
3 | import { useFiber, useFiberMaps } from "../../utils/fiber-maps";
4 | import { usePinnedContext } from "../../utils/pinned";
5 | import FiberId from "../common/FiberId";
6 | import TreeLeafCaption from "./TreeLeafCaption";
7 |
8 | function FiberLink({
9 | id,
10 | name,
11 | pin,
12 | }: {
13 | id: number;
14 | name: string | null;
15 | pin: (id: number) => void;
16 | }) {
17 | return (
18 |
19 | pin(id)}
22 | >
23 | {name || "Unknown"}
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | function FiberPath({
31 | fiber,
32 | groupByParent,
33 | }: {
34 | fiber: MessageFiber;
35 | groupByParent: boolean;
36 | }) {
37 | const { fiberById } = useFiberMaps();
38 | const { pin } = usePinnedContext();
39 | const path = [];
40 | const ancestorProp = groupByParent ? "parentId" : "ownerId";
41 | let cursor = fiber[ancestorProp];
42 |
43 | while (cursor !== 0) {
44 | const ancestor = fiberById.get(cursor);
45 |
46 | path.unshift(
47 |
53 | );
54 |
55 | cursor = ancestor?.[ancestorProp] || 0;
56 | }
57 |
58 | if (path.length === 0) {
59 | return null;
60 | }
61 |
62 | return {path}
;
63 | }
64 |
65 | const FiberTreeHeader = React.memo(
66 | ({
67 | rootId,
68 | groupByParent,
69 | showTimings,
70 | }: {
71 | rootId: number;
72 | groupByParent: boolean;
73 | showTimings: boolean;
74 | }) => {
75 | const fiber = useFiber(rootId);
76 |
77 | if (rootId === 0 || !fiber) {
78 | return null;
79 | }
80 |
81 | return (
82 |
83 |
84 |
90 |
91 | );
92 | }
93 | );
94 |
95 | FiberTreeHeader.displayName = "FiberTreeHeader";
96 |
97 | export default FiberTreeHeader;
98 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeaf.css:
--------------------------------------------------------------------------------
1 | .tree-leaf.render-root {
2 | --scroll-margin-top: 21px;
3 | }
4 | .tree-leaf.render-root:not(:last-child) {
5 | border-bottom: 1px solid #ddd;
6 | }
7 | .tree-leaf-caption.render-root + .tree-leaf {
8 | margin-top: 1px;
9 | }
10 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeaf.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useFiber, useFiberChildren } from "../../utils/fiber-maps";
3 | import { useTreeViewSettingsContext } from "./contexts";
4 | import TreeLeafCaption from "./TreeLeafCaption";
5 |
6 | export interface TreeLeafProps {
7 | fiberId: number;
8 | depth?: number;
9 | }
10 |
11 | const TreeLeaf = React.memo(({ fiberId, depth = 0 }: TreeLeafProps) => {
12 | const { setFiberElement, groupByParent, showUnmounted, showTimings } =
13 | useTreeViewSettingsContext();
14 | const fiber = useFiber(fiberId);
15 | const children = useFiberChildren(fiberId, groupByParent, showUnmounted);
16 | const [expanded, setExpanded] = React.useState(true);
17 | const hasChildren = children.length > 0;
18 |
19 | if (!fiber) {
20 | return null;
21 | }
22 |
23 | return (
24 |
25 |
33 |
34 | {expanded &&
35 | children.map(childId => (
36 |
37 | ))}
38 |
39 | );
40 | });
41 |
42 | TreeLeaf.displayName = "TreeLeaf";
43 |
44 | export default TreeLeaf;
45 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeafCaption.css:
--------------------------------------------------------------------------------
1 | .tree-leaf-caption {
2 | display: flex;
3 | box-sizing: border-box;
4 | border: solid transparent;
5 | border-width: 1px 0;
6 | background-color: white;
7 | white-space: nowrap;
8 | color: #0279c9;
9 | font-size: 12px;
10 | line-height: 20px;
11 | cursor: pointer;
12 | }
13 |
14 | .tree-leaf-caption.render-root {
15 | --scroll-margin-top: 0px;
16 |
17 | border-top: none;
18 | border-color: #ddd;
19 | background-color: #f4f4f4;
20 | color: #666;
21 | }
22 | .fiber-tree .tree-leaf-caption.render-root {
23 | position: sticky;
24 | z-index: 2;
25 | top: 0;
26 | margin-bottom: -1px;
27 | }
28 | .tree-leaf-caption.pinned:not(.render-root):not(.selected) {
29 | border-bottom-color: #ddd;
30 | }
31 | .tree-leaf-caption.pinned::before {
32 | content: "";
33 | display: inline-block;
34 | vertical-align: middle;
35 | width: 20px;
36 | height: 18px;
37 | margin-right: -20px;
38 | background: url("../../images/pin.svg") no-repeat center 3px;
39 | background-size: 14px;
40 | }
41 |
42 | .tree-leaf-caption:hover {
43 | background-color: #eee;
44 | }
45 | .tree-leaf-caption.highlighted {
46 | background-color: #eee;
47 | }
48 | .tree-leaf-caption:hover:not(.selected) {
49 | border-color: #e6e6e6;
50 | }
51 | .tree-leaf-caption.selected {
52 | background-color: #dde8f4;
53 | border-color: #cfdeef;
54 | cursor: default;
55 | }
56 | .tree-leaf-caption.selected.render-root {
57 | background-color: #deebf7;
58 | }
59 | .tree-leaf-caption.pinned {
60 | border-top-color: transparent;
61 | }
62 |
63 | .tree-leaf-caption__unpin-button {
64 | margin: 2px 1px 2px;
65 | padding: 0px 4px;
66 | font-size: 10px;
67 | color: rgba(0, 0, 0, 0.75);
68 | text-transform: uppercase;
69 | background: rgba(0, 0, 0, 0.075);
70 | border: 1px solid rgba(0, 0, 0, 0.15);
71 | border-radius: 3px;
72 | cursor: pointer;
73 | opacity: 0.8;
74 | }
75 | .tree-leaf-caption__unpin-button:hover {
76 | opacity: 1;
77 | }
78 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeafCaptionContent.css:
--------------------------------------------------------------------------------
1 | .tree-leaf-caption.pinned .tree-leaf-caption__main {
2 | width: 0;
3 | overflow: hidden;
4 | }
5 |
6 | .tree-leaf-caption__main {
7 | flex: 1;
8 | padding-left: calc(var(--depth, 0) * 14px);
9 | }
10 | .tree-leaf-caption__main-content {
11 | display: inline-block;
12 | padding-left: 19px;
13 | padding-right: 4px;
14 | scroll-margin-top: var(--scroll-margin-top, 0px);
15 | scroll-margin-left: var(--scroll-margin-left, 0px);
16 | }
17 |
18 | .tree-leaf-caption__main-content .fiber-maybe-leak {
19 | margin-left: 2px;
20 | border: 1px solid rgba(255, 255, 255, 0.65);
21 | }
22 |
23 | .tree-leaf-caption-content__name {
24 | position: relative;
25 | }
26 | .tree-leaf-caption-content__name.host-type {
27 | color: #8c629a;
28 | }
29 | .tree-leaf-caption-content__name.no-events {
30 | color: #90bce5;
31 | }
32 | .tree-leaf-caption-content__name.unmounted {
33 | color: #aaa;
34 | text-decoration: line-through;
35 | text-decoration-color: #888;
36 | transition: 0.25s;
37 | transition-property: color;
38 | }
39 | .tree-leaf-caption-content__highlight {
40 | margin: 0 0 -2px;
41 | border-bottom: 2px solid #e0bf08;
42 | }
43 |
44 | .tree-leaf-caption__root-mode {
45 | margin-left: 2px;
46 | padding: 2px 6px;
47 | border: 1px solid rgba(255, 255, 255, 0.65);
48 | border-radius: 10px;
49 | background-color: #e0e0e0;
50 | text-decoration: none;
51 | color: #888;
52 | font-size: 10px;
53 | }
54 | .tree-leaf-caption__root-mode:hover {
55 | text-decoration: underline;
56 | }
57 |
58 | .tree-leaf-caption__update-count,
59 | .tree-leaf-caption__update-bailout-count,
60 | .tree-leaf-caption__context-count {
61 | display: inline-block;
62 | min-width: 16px;
63 | margin-left: 2px;
64 | padding: 3px 4px 2.3px;
65 | border: 1px solid rgba(255, 255, 255, 0.65);
66 | border-radius: 3px;
67 | background-color: #eaeac7;
68 | background-clip: padding-box;
69 | color: #949c35;
70 | font-size: 10px;
71 | line-height: 10px;
72 | text-align: center;
73 | }
74 | /* .tree-leaf-caption__update-bailout-count {
75 | background-color: #ffbdbd;
76 | color: #e1373e;
77 | } */
78 | .tree-leaf-caption__update-bailout-count {
79 | background-color: #d5e5f1;
80 | color: #8594a1;
81 | }
82 | .tree-leaf-caption__context-count {
83 | background-color: #eadcea;
84 | color: #ca8fd0;
85 | }
86 |
87 | .tree-leaf-caption__warnings {
88 | content: "";
89 | display: inline-block;
90 | vertical-align: middle;
91 | margin-top: -1px;
92 | margin-left: 3px;
93 | margin-right: -1px;
94 | width: 11px;
95 | height: 11px;
96 | background: url("../../images/warning-exc-sign.svg") no-repeat center #eab439;
97 | background-size: 7px;
98 | background-clip: content-box;
99 | border-radius: 3px;
100 | border: 1px solid rgba(255, 255, 255, 0.65);
101 | }
102 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeafTimings.css:
--------------------------------------------------------------------------------
1 | .tree-leaf-timings {
2 | position: sticky;
3 | left: 0;
4 | z-index: 1;
5 | background-color: inherit;
6 | }
7 | .tree-leaf-timings__time {
8 | display: inline-block;
9 | box-sizing: border-box;
10 | width: 45px;
11 | padding: 1px 5px 0 0;
12 | text-align: right;
13 | font-size: 10px;
14 | color: #888;
15 | }
16 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/TreeLeafTimings.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { MessageFiber } from "../../types";
3 | import { formatDuration } from "../../utils/duration";
4 |
5 | const TreeLeafTimings = ({ fiber }: { fiber: MessageFiber }) => {
6 | const { events, selfTime, totalTime } = fiber;
7 |
8 | return (
9 |
10 |
11 | {events.length > 0 ? formatDuration(selfTime) : "\xA0"}
12 |
13 |
14 | {events.length > 0 ? formatDuration(totalTime) : "\xA0"}
15 |
16 |
17 | );
18 | };
19 |
20 | const TreeLeafTimingsMemo = React.memo(TreeLeafTimings);
21 | TreeLeafTimingsMemo.displayName = "TreeLeafTimings";
22 |
23 | export default TreeLeafTimingsMemo;
24 |
--------------------------------------------------------------------------------
/src/ui/components/fiber-tree/contexts.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export interface TreeViewSettings {
4 | setFiberElement: (id: number, element: HTMLElement | null) => void;
5 | getFiberElement: (id: number) => HTMLElement | null;
6 | groupByParent: boolean;
7 | showUnmounted: boolean;
8 | showTimings: boolean;
9 | }
10 | export const TreeViewSettingsContext = React.createContext({
11 | setFiberElement: () => undefined,
12 | getFiberElement: () => null,
13 | groupByParent: false,
14 | showUnmounted: true,
15 | showTimings: false,
16 | });
17 | export const useTreeViewSettingsContext = () =>
18 | React.useContext(TreeViewSettingsContext);
19 |
--------------------------------------------------------------------------------
/src/ui/components/misc/FiberTreeKeyboardNav.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useFiberMaps } from "../../utils/fiber-maps";
3 | import { useSelectedId } from "../../utils/selection";
4 |
5 | const FiberTreeKeyboardNav = React.memo(function ({
6 | groupByParent,
7 | showUnmounted,
8 | }: {
9 | groupByParent: boolean;
10 | showUnmounted: boolean;
11 | }) {
12 | const { selectedId, select } = useSelectedId();
13 | const { selectTree } = useFiberMaps();
14 | const tree = selectTree(groupByParent, showUnmounted);
15 | const handleKeyDown = React.useCallback(
16 | (event: KeyboardEvent) => {
17 | let id;
18 |
19 | switch (event.code) {
20 | case "ArrowUp": {
21 | id = (tree.get(selectedId as number)?.prev || tree.root.lastChild)
22 | ?.fiber?.id;
23 | break;
24 | }
25 |
26 | case "ArrowDown": {
27 | id = (tree.get(selectedId as number)?.next || tree.root.firstChild)
28 | ?.fiber?.id;
29 | break;
30 | }
31 |
32 | default:
33 | return;
34 | }
35 |
36 | event.preventDefault();
37 | event.stopPropagation();
38 |
39 | if (typeof id === "number") {
40 | select(id);
41 | }
42 | },
43 | [tree, selectedId, select]
44 | );
45 |
46 | React.useEffect(() => {
47 | document.addEventListener("keydown", handleKeyDown);
48 | return () => document.removeEventListener("keydown", handleKeyDown);
49 | }, [handleKeyDown]);
50 |
51 | return null;
52 | });
53 |
54 | FiberTreeKeyboardNav.displayName = "FiberTreeKeyboardNav";
55 | export default FiberTreeKeyboardNav;
56 |
--------------------------------------------------------------------------------
/src/ui/components/misc/WaitingForReady.css:
--------------------------------------------------------------------------------
1 | @keyframes waiting-for-ready-appear {
2 | from,
3 | 33% {
4 | visibility: hidden;
5 | opacity: 0;
6 | }
7 | to {
8 | visibility: visible;
9 | opacity: 1;
10 | }
11 | }
12 |
13 | .waiting-for-ready {
14 | position: relative;
15 | grid-area: page;
16 | padding: 12px 16px;
17 | font-size: 13px;
18 | color: #888;
19 | animation: 1.5s 1 waiting-for-ready-appear;
20 | }
21 |
--------------------------------------------------------------------------------
/src/ui/components/misc/WaitingForReady.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useEventsContext } from "../../utils/events";
3 | import { useFiberChildren } from "../../utils/fiber-maps";
4 |
5 | export default function WaitingForReady({
6 | children,
7 | }: {
8 | children: JSX.Element;
9 | }) {
10 | const fiberRoots = useFiberChildren(0);
11 | const { loadedEventsCount, totalEventsCount } = useEventsContext();
12 |
13 | if (fiberRoots.length > 0) {
14 | return children;
15 | }
16 |
17 | return (
18 |
19 | {totalEventsCount > 0
20 | ? loadedEventsCount === totalEventsCount
21 | ? "Rendering..."
22 | : `Loading events (${Math.trunc(
23 | (100 * loadedEventsCount) / totalEventsCount
24 | )}%)...`
25 | : "Waiting for a React render root to be mounted..."}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/components/misc/WaitingForRenderer.css:
--------------------------------------------------------------------------------
1 | @keyframes waiting-for-renderer-appear {
2 | from,
3 | 33% {
4 | visibility: hidden;
5 | opacity: 0;
6 | }
7 | to {
8 | visibility: visible;
9 | opacity: 1;
10 | }
11 | }
12 |
13 | .waiting-for-renderer {
14 | padding: 12px 16px;
15 | font-size: 13px;
16 | color: #888;
17 | animation: 1.5s 1 waiting-for-renderer-appear;
18 | }
19 |
20 | .unsupported-renderers {
21 | margin: 1em 0 0;
22 | color: #a07b36;
23 | }
24 | .unsupported-renderers__list {
25 | margin: 0;
26 | padding: 0 0 0 20px;
27 | }
28 |
--------------------------------------------------------------------------------
/src/ui/components/misc/WaitingForRenderer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useReactRenderers } from "../../utils/react-renderers";
3 |
4 | export default function WaitingForRenderer({
5 | children,
6 | }: {
7 | children: JSX.Element;
8 | }) {
9 | const { selected: selectedReactInstance, unsupportedRenderers } =
10 | useReactRenderers();
11 |
12 | if (selectedReactInstance) {
13 | return children;
14 | }
15 |
16 | return (
17 |
18 | Waiting for a supported React renderer to be connected...
19 | {!unsupportedRenderers.length ? null : (
20 |
21 |
Detected unsupported renderers:
22 |
23 | {unsupportedRenderers.map(info => (
24 | -
25 |
26 | {info.name} v{info.version}
27 |
28 | {" – "}
29 | {info.reason}
30 |
31 | ))}
32 |
33 |
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/ui/components/statebar/StateBar.css:
--------------------------------------------------------------------------------
1 | .statebar-exposed-leaks {
2 | display: flex;
3 | padding: 4px 8px;
4 | font-size: 12px;
5 | background-color: #f5f5d3;
6 | color: #898927;
7 | border-top: 1px solid #ddd;
8 | }
9 | .statebar-exposed-leaks__message {
10 | flex: 1;
11 | }
12 | .statebar-exposed-leaks__cancel-button {
13 | border: none;
14 | background: none;
15 | margin: -4px -8px -4px 0;
16 | padding: 0 8px;
17 | opacity: 0.5;
18 | }
19 | .statebar-exposed-leaks__cancel-button:hover,
20 | .statebar-exposed-leaks__cancel-button:active {
21 | background: #e1e1ba;
22 | cursor: pointer;
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/components/statebar/StateBar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useMemoryLeaks } from "../../utils/memory-leaks";
3 | import { Cancel as CancelIcon } from "../common/icons";
4 |
5 | function ExposedLeaks() {
6 | const { exposedLeaks, cancelExposingLeakedObjectsToGlobal } =
7 | useMemoryLeaks();
8 |
9 | if (!exposedLeaks) {
10 | return null;
11 | }
12 |
13 | const fiberCount = exposedLeaks.fiberIds.length;
14 | const objectCount = exposedLeaks.objectRefsCount;
15 |
16 | return (
17 |
18 |
19 | {objectCount} potentially leaked React object
20 | {objectCount > 1 ? "s" : ""} ({fiberCount} fibers) stored as a global
21 | variable {exposedLeaks.globalName}
22 |
23 |
29 |
30 | );
31 | }
32 |
33 | function StateBar() {
34 | return (
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | const StateBarMemo = React.memo(StateBar);
42 | StateBarMemo.displayName = "StateBar";
43 |
44 | export default StateBarMemo;
45 |
--------------------------------------------------------------------------------
/src/ui/components/statusbar/StatusBar.css:
--------------------------------------------------------------------------------
1 | .statusbar {
2 | grid-area: statusbar;
3 | display: flex;
4 | border-top: 1px solid #ddd;
5 | line-height: 12px;
6 | font-size: 10px;
7 | color: #aaa;
8 | }
9 |
10 | .statusbar__summary {
11 | flex: 1;
12 | padding: 4px 8px;
13 | }
14 |
15 | .statusbar__paused {
16 | display: flex;
17 | align-items: center;
18 | padding: 2px 2px;
19 | }
20 | .statusbar__paused::before {
21 | content: "paused";
22 | border: 1px solid #e5d88c;
23 | background: #fdfcde;
24 | color: #c7b639;
25 | border-radius: 2px;
26 | padding: 1px 3px;
27 | text-transform: uppercase;
28 | font-size: 9px;
29 | vertical-align: top;
30 | }
31 |
32 | .statusbar__pending {
33 | position: relative;
34 | padding: 4px;
35 | min-width: 90px;
36 | border-left: 1px solid #eee;
37 | text-align: center;
38 | }
39 | .statusbar__pending::before,
40 | .statusbar__pending::after {
41 | content: "";
42 | position: absolute;
43 | bottom: 2px;
44 | left: 4px;
45 | right: 4px;
46 | height: 2px;
47 | background-color: #ddd;
48 | }
49 | .statusbar__pending::after {
50 | right: auto;
51 | width: calc(var(--progress, 0) - 8px);
52 | transition: width 100ms ease-out;
53 | background-color: #44a0dd;
54 | }
55 |
56 | .statusbar__event-type-count {
57 | border-left: 1px solid #eee;
58 | padding: 4px;
59 | }
60 | .statusbar__event-type-count::before {
61 | content: attr(data-type) "s: ";
62 | color: #ccc;
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui/components/statusbar/StatusBar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useEventsContext } from "../../utils/events";
3 | import { useFiberMaps } from "../../utils/fiber-maps";
4 |
5 | function plural(num: number, single: string, multiple = single + "s") {
6 | return `${num} ${num === 1 ? single : multiple}`;
7 | }
8 |
9 | function bytesFormatted(value: number) {
10 | const units = [" bytes", "Kb", "MB"];
11 |
12 | while (value > 1024 && units.length > 1) {
13 | value /= 1024;
14 | units.shift();
15 | }
16 |
17 | return `${units.length === 3 ? value : value.toFixed(1)}${units[0]}`;
18 | }
19 |
20 | const StatusBar = () => {
21 | const {
22 | loadingStartOffset,
23 | loadedEventsCount,
24 | totalEventsCount,
25 | bytesReceived,
26 | mountCount,
27 | unmountCount,
28 | updateCount,
29 | paused,
30 | } = useEventsContext();
31 | const pendingEventsCount = totalEventsCount - loadedEventsCount;
32 | const { fiberById } = useFiberMaps();
33 | const fiberCount = fiberById.size;
34 |
35 | return (
36 |
37 |
38 | {totalEventsCount > 0
39 | ? `${plural(loadedEventsCount, "event")} (${bytesFormatted(
40 | bytesReceived
41 | )})`
42 | : "No events"}
43 | {fiberCount > 0
44 | ? ` for ${plural(fiberCount, "component instance")}`
45 | : ""}
46 |
47 | {paused && }
48 | {pendingEventsCount > 0 && (
49 |
60 | {plural(pendingEventsCount, "pending event")}
61 |
62 | )}
63 |
64 | {mountCount}
65 |
66 |
67 | {updateCount}
68 |
69 |
70 | {unmountCount}
71 |
72 |
73 | );
74 | };
75 |
76 | const StatusBarMemo = React.memo(StatusBar);
77 | StatusBarMemo.displayName = "StatusBar";
78 |
79 | export default StatusBarMemo;
80 |
--------------------------------------------------------------------------------
/src/ui/components/toolbar/ComponentSearch.css:
--------------------------------------------------------------------------------
1 | .component-search {
2 | flex: 1;
3 | display: flex;
4 | align-items: center;
5 | min-width: 275px;
6 | }
7 | .component-search::after {
8 | content: "";
9 | display: inline-block;
10 | height: 20px;
11 | border-right: 1px solid #ddd;
12 | align-self: center;
13 | }
14 |
15 | .component-search > svg {
16 | flex-basis: 0;
17 | min-width: 14px;
18 | margin-left: 6px;
19 | color: #ccc;
20 | }
21 | .component-search:focus-within > svg {
22 | color: #999;
23 | }
24 |
25 | .component-search input {
26 | box-sizing: border-box;
27 | width: 100%;
28 | padding: 5px 6px;
29 | border: none;
30 | outline: none;
31 | font: inherit;
32 | line-height: inherit;
33 | background-color: transparent;
34 | color: inherit;
35 | }
36 | .component-search input::placeholder {
37 | color: #ccc;
38 | }
39 |
40 | .component-search__buttons {
41 | padding: 0px 4px 0px 4px;
42 | margin-left: -4px;
43 | border-left: 1px solid #ccc;
44 | height: 15px;
45 | box-sizing: content-box;
46 | }
47 | .component-search__button {
48 | vertical-align: middle;
49 | padding: 2px 2px;
50 | margin-top: -10px;
51 | background: none;
52 | border: none;
53 | color: #777;
54 | }
55 | .component-search__button:not([disabled]):hover {
56 | cursor: pointer;
57 | color: #222;
58 | }
59 | .component-search__button svg {
60 | vertical-align: middle;
61 | height: 12px;
62 | margin: -2px 0 0;
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui/components/toolbar/SearchMatchesNav.css:
--------------------------------------------------------------------------------
1 | .component-search-matches-nav {
2 | display: flex;
3 | align-items: center;
4 | padding: 6px;
5 | white-space: nowrap;
6 | font-size: 12px;
7 | line-height: 12px;
8 | color: #888;
9 | }
10 | .component-search-matches-nav__buttons {
11 | margin-left: 6px;
12 | padding-left: 2px;
13 | border-left: 1px solid #ccc;
14 | }
15 | .component-search-matches-nav__button {
16 | vertical-align: middle;
17 | padding: 2px 2px;
18 | margin-top: -2px;
19 | margin-bottom: -2px;
20 | background: none;
21 | border: none;
22 | color: #777;
23 | }
24 | .component-search-matches-nav__button[disabled] {
25 | color: #ccc;
26 | }
27 | .component-search-matches-nav__button:not([disabled]):hover {
28 | cursor: pointer;
29 | color: #222;
30 | }
31 | .component-search-matches-nav__button svg {
32 | vertical-align: middle;
33 | height: 12px;
34 | width: 14px;
35 | margin: -2px 0 0;
36 | }
37 |
--------------------------------------------------------------------------------
/src/ui/components/toolbar/SelectionHistoryNavigation.css:
--------------------------------------------------------------------------------
1 | .selection-history-navigation {
2 | display: flex;
3 | }
4 | .selection-history-navigation::after {
5 | content: "";
6 | display: inline-block;
7 | height: 20px;
8 | border-right: 1px solid #ddd;
9 | align-self: center;
10 | }
11 | .selection-history-navigation__button {
12 | padding: 0;
13 | background: none;
14 | border: none;
15 | color: #666;
16 | }
17 | .selection-history-navigation__button_prev {
18 | border-left: 2px solid transparent;
19 | }
20 | .selection-history-navigation__button_next {
21 | border-right: 2px solid transparent;
22 | }
23 | .selection-history-navigation__button[disabled] {
24 | color: #aaa;
25 | }
26 | .selection-history-navigation__button:hover:not([disabled]) {
27 | cursor: pointer;
28 | color: #000;
29 | background-repeat: no-repeat;
30 | background-clip: padding-box;
31 | background-image: radial-gradient(
32 | circle at center,
33 | #f4f4f4 11px,
34 | transparent 11px
35 | );
36 | }
37 | .selection-history-navigation__button svg {
38 | vertical-align: middle;
39 | width: 22px;
40 | height: 20px;
41 | }
42 |
--------------------------------------------------------------------------------
/src/ui/components/toolbar/SelectionHistoryNavigation.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectionHistoryState } from "../../utils/selection";
3 | import { Back, Forward } from "../common/icons";
4 |
5 | const SelectionHistoryNavigation = () => {
6 | const { hasPrev, hasNext, prev, next } = useSelectionHistoryState();
7 |
8 | return (
9 |
10 |
17 |
24 |
25 | );
26 | };
27 |
28 | export default SelectionHistoryNavigation;
29 |
--------------------------------------------------------------------------------
/src/ui/components/toolbar/Toolbar.css:
--------------------------------------------------------------------------------
1 | .toolbar {
2 | grid-area: toolbar;
3 | display: flex;
4 | flex-shrink: 0;
5 | flex-wrap: wrap;
6 | justify-content: flex-end;
7 | gap: 1px 0;
8 | border-bottom: 1px solid #ddd;
9 | background-color: rgba(255, 255, 255, 0.75);
10 | background-image: linear-gradient(
11 | to bottom,
12 | transparent 32px,
13 | #ddd 32px,
14 | #ddd 33px,
15 | transparent 0px
16 | );
17 | line-height: 22px;
18 | }
19 | .toolbar__buttons {
20 | position: relative;
21 | display: flex;
22 | align-items: center;
23 | margin: 2px;
24 | gap: 1px;
25 | }
26 | .toolbar__buttons-splitter {
27 | display: inline-block;
28 | height: 20px;
29 | border-left: 1px solid #ddd;
30 | margin: 0 1px;
31 | }
32 |
--------------------------------------------------------------------------------
/src/ui/images/dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-effect-create.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-effect-destroy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-mount.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-unmount.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-update-bailout-memo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-update-bailout-state.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/event-update.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/expander.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/ui/images/expose-to-global.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/images/fiber-key-tail.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/pick-component.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/images/pin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/source-loc-open.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/source-loc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/table-sort-asc.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/images/table-sort-desc.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/images/table-sortable.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/images/update-trigger.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/warning-exc-sign.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/images/warning.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 |
5 | // bootstrap HTML document
6 | declare let __CSS__: string;
7 | const rootEl = document.createElement("div");
8 | document.head.appendChild(document.createElement("style")).append(__CSS__);
9 | document.body.appendChild(rootEl);
10 |
11 | // That's actually a hack.
12 | // React add 2x listeners for all known events (one for capture and one for bubbling phases),
13 | // and perform search for a proper fiber and event handlers on it. It turns out that
14 | // on each pointer move there are 4-12 handlers are firing (pointermove & mousemove
15 | // and optionally pointerover, pointerout, mouseover, mouseout). Currently, we don't use
16 | // such event handlers, so avoid adding listeners for them to improve hover performance.
17 | const rootElAddEventListener = rootEl.addEventListener;
18 | rootEl.addEventListener = (
19 | ...args: Parameters
20 | ) => {
21 | if (
22 | !/^(pointer|mouse)/.test(args[0]) ||
23 | ["mouseover", "mouseout"].includes(args[0])
24 | ) {
25 | rootElAddEventListener.call(rootEl, ...args);
26 | }
27 | };
28 |
29 | // render React app
30 | ReactDOM.createRoot(rootEl).render();
31 |
--------------------------------------------------------------------------------
/src/ui/pages/Commits.css:
--------------------------------------------------------------------------------
1 | .app-page-commits {
2 | overflow-y: scroll;
3 | }
4 |
5 | .app-page-commits tbody td {
6 | text-align: right;
7 | min-width: 32px;
8 | }
9 |
--------------------------------------------------------------------------------
/src/ui/pages/Commits.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectedId } from "../utils/selection";
3 | import { useCommits } from "../utils/fiber-maps";
4 |
5 | function CommitsPageBadge() {
6 | const commits = useCommits();
7 | return {commits.length};
8 | }
9 |
10 | function CommitsPage() {
11 | const { selectedId } = useSelectedId();
12 | const commits = useCommits().slice(-20).reverse();
13 |
14 | return (
15 |
19 |
20 |
21 |
22 | # |
23 | Mounts |
24 | Updates |
25 | Unmounts |
26 |
27 |
28 |
29 | {commits.map(commit => {
30 | const stat = commit.events.reduce((stat, event) => {
31 | stat[event.op] = (stat[event.op] || 0) + 1;
32 | return stat;
33 | }, Object.create(null));
34 | console.log(commit);
35 |
36 | return (
37 |
38 | {commit.commitId} |
39 | {stat.mount || ""} |
40 | {stat.update || ""} |
41 | {stat.unmount || ""} |
42 |
43 | );
44 | })}
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | const CommitsPageBadgeMemo = React.memo(CommitsPageBadge);
52 | CommitsPageBadgeMemo.displayName = "CommitsPageBadge";
53 |
54 | const CommitsPageMemo = React.memo(CommitsPage);
55 | CommitsPageMemo.displayName = "CommitsPage";
56 |
57 | export {
58 | CommitsPageMemo as CommitsPage,
59 | CommitsPageBadgeMemo as CommitsPageBadge,
60 | };
61 |
--------------------------------------------------------------------------------
/src/ui/pages/Components.css:
--------------------------------------------------------------------------------
1 | .app-page-components {
2 | display: grid;
3 | grid-template:
4 | "toolbar"
5 | "content";
6 | grid-template-rows: auto 1fr;
7 | }
8 |
9 | .app-page-components .app-page-content-wrapper {
10 | grid-area: content;
11 | overflow-y: scroll;
12 | overflow-x: auto;
13 | position: relative;
14 | }
15 | .app-page-components .app-page-content {
16 | position: absolute;
17 | min-width: 100%;
18 | }
19 |
--------------------------------------------------------------------------------
/src/ui/pages/Components.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectedId } from "../utils/selection";
3 | import { useFiberTypeStat } from "../utils/fiber-maps";
4 | import ComponentsTable from "./components/ComponentsTable";
5 | import Toolbar from "./components/Toolbar";
6 |
7 | function ComponentsPageBadge() {
8 | const { length: count } = useFiberTypeStat();
9 | return count || null;
10 | }
11 |
12 | function ComponentsPage() {
13 | const { selectedId } = useSelectedId();
14 | const [filter, setFilter] = React.useState("");
15 |
16 | return (
17 |
28 | );
29 | }
30 |
31 | const ComponentsPageBadgeMemo = React.memo(ComponentsPageBadge);
32 | ComponentsPageBadgeMemo.displayName = "ComponentsPageBadge";
33 |
34 | const ComponentsPageMemo = React.memo(ComponentsPage);
35 | ComponentsPageMemo.displayName = "ComponentsPage";
36 |
37 | export {
38 | ComponentsPageMemo as ComponentsPage,
39 | ComponentsPageBadgeMemo as ComponentsPageBadge,
40 | };
41 |
--------------------------------------------------------------------------------
/src/ui/pages/ComponentsTree.css:
--------------------------------------------------------------------------------
1 | .app-page-components-tree {
2 | grid-area: page;
3 | display: grid;
4 | grid-template-areas:
5 | "toolbar"
6 | "fiber-tree-header"
7 | "fiber-tree";
8 | grid-template-rows: auto auto 1fr;
9 | }
10 |
11 | .app-page-components-tree[data-has-selected] {
12 | grid-template-columns: 1fr 1fr;
13 | grid-template-areas:
14 | "toolbar toolbar"
15 | "fiber-tree-header details"
16 | "fiber-tree details";
17 | }
18 |
--------------------------------------------------------------------------------
/src/ui/pages/ComponentsTree.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FindMatchContextProvider } from "../utils/find-match";
3 | import Toolbar from "../components/toolbar/Toolbar";
4 | import FiberTree from "../components/fiber-tree/Tree";
5 | import FiberTreeHeader from "../components/fiber-tree/TreeHeader";
6 | import Details from "../components/details/Details";
7 | import FiberTreeKeyboardNav from "../components/misc/FiberTreeKeyboardNav";
8 | import { useSelectedId } from "../utils/selection";
9 | import { usePinnedId } from "../utils/pinned";
10 |
11 | function ComponentsTreePage() {
12 | const [groupByParent, setGroupByParent] = React.useState(false);
13 | const [showUnmounted, setShowUnmounted] = React.useState(true);
14 | const [showTimings, setShowTimings] = React.useState(false);
15 | const { selectedId } = useSelectedId();
16 | const { pinnedId } = usePinnedId();
17 |
18 | return (
19 |
23 |
24 |
32 |
33 |
38 |
42 |
48 |
49 |
50 | {selectedId !== null && (
51 |
57 | )}
58 |
59 | );
60 | }
61 |
62 | const ComponentsTreePageMemo = React.memo(ComponentsTreePage);
63 | ComponentsTreePageMemo.displayName = "ComponentsTreePage";
64 |
65 | export { ComponentsTreePageMemo as ComponentsTreePage };
66 |
--------------------------------------------------------------------------------
/src/ui/pages/MaybeLeaks.css:
--------------------------------------------------------------------------------
1 | .app-page-maybe-leaks {
2 | display: grid;
3 | grid-template:
4 | "toolbar"
5 | "content";
6 | grid-template-rows: auto 1fr;
7 | }
8 | .app-page-maybe-leaks[data-has-selected] {
9 | grid-template-areas:
10 | "toolbar toolbar"
11 | "content details";
12 | grid-template-columns: 1fr 1fr;
13 | }
14 | .app-page-maybe-leaks .app-page-content-wrapper {
15 | grid-area: content;
16 | overflow-y: scroll;
17 | overflow-x: auto;
18 | position: relative;
19 | }
20 | .app-page-maybe-leaks .app-page-content {
21 | position: absolute;
22 | min-width: 100%;
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/pages/MaybeLeaks.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useSelectedId } from "../utils/selection";
3 | import {
4 | useFiber,
5 | useFiberMaps,
6 | useLeakedFibers,
7 | useTypeIdFibers,
8 | } from "../utils/fiber-maps";
9 | import Toolbar from "./maybe-leaks/Toolbar";
10 | import { LeaksList } from "./maybe-leaks/LeaksList";
11 | import FiberDetails from "../components/details/Details";
12 |
13 | function MaybeLeaksPageBadge() {
14 | const { length: count } = useLeakedFibers();
15 | return count || null;
16 | }
17 |
18 | function MaybeLeaksPage() {
19 | const [groupByParent, setGroupByParent] = React.useState(false);
20 | const [showUnmounted, setShowUnmounted] = React.useState(true);
21 | const [showTimings, setShowTimings] = React.useState(false);
22 | const { fiberById } = useFiberMaps();
23 | const { selectedId } = useSelectedId();
24 | const selectedFiber = useFiber(selectedId || -1);
25 | const typeFiberIds = useTypeIdFibers(selectedFiber?.typeId || -1);
26 | const unmountedSelectedFiber =
27 | selectedFiber &&
28 | !selectedFiber.mounted &&
29 | typeFiberIds.some(fiberId => fiberById.get(fiberId)?.leaked)
30 | ? selectedFiber
31 | : null;
32 |
33 | return (
34 |
38 |
46 |
51 | {unmountedSelectedFiber && (
52 |
58 | )}
59 |
60 | );
61 | }
62 |
63 | const MaybeLeaksPageBadgeMemo = React.memo(MaybeLeaksPageBadge);
64 | MaybeLeaksPageBadgeMemo.displayName = "MaybeLeaksPageBadge";
65 |
66 | const MaybeLeaksPageMemo = React.memo(MaybeLeaksPage);
67 | MaybeLeaksPageMemo.displayName = "MaybeLeaksPage";
68 |
69 | export {
70 | MaybeLeaksPageMemo as MaybeLeaksPage,
71 | MaybeLeaksPageBadgeMemo as MaybeLeaksPageBadge,
72 | };
73 |
--------------------------------------------------------------------------------
/src/ui/pages/components/ComponentSearch.css:
--------------------------------------------------------------------------------
1 | .app-page-components .component-search {
2 | flex: 1;
3 | display: flex;
4 | align-items: center;
5 | min-width: 275px;
6 | }
7 | .app-page-components .component-search::after {
8 | content: "";
9 | display: inline-block;
10 | height: 20px;
11 | border-right: 1px solid #ddd;
12 | align-self: center;
13 | }
14 |
15 | .app-page-components .component-search > svg {
16 | flex-basis: 0;
17 | min-width: 14px;
18 | margin-left: 6px;
19 | color: #ccc;
20 | }
21 | .app-page-components .component-search:focus-within > svg {
22 | color: #999;
23 | }
24 |
25 | .app-page-components .component-search input {
26 | box-sizing: border-box;
27 | width: 100%;
28 | padding: 5px 6px;
29 | border: none;
30 | outline: none;
31 | font: inherit;
32 | line-height: inherit;
33 | background-color: transparent;
34 | color: inherit;
35 | }
36 | .app-page-components .component-search input::placeholder {
37 | color: #ccc;
38 | }
39 |
40 | .app-page-components .component-search__buttons {
41 | padding: 0px 4px 0px 4px;
42 | margin-left: -4px;
43 | border-left: 1px solid #ccc;
44 | height: 15px;
45 | box-sizing: content-box;
46 | }
47 | .app-page-components .component-search__button {
48 | vertical-align: middle;
49 | padding: 2px 2px;
50 | margin-top: -10px;
51 | background: none;
52 | border: none;
53 | color: #777;
54 | }
55 | .app-page-components .component-search__button:not([disabled]):hover {
56 | cursor: pointer;
57 | color: #222;
58 | }
59 | .app-page-components .component-search__button svg {
60 | vertical-align: middle;
61 | height: 12px;
62 | margin: -2px 0 0;
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui/pages/components/ComponentSearch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Cancel as CancelIcon,
4 | Search as SearchIcon,
5 | } from "../../components/common/icons";
6 |
7 | interface ComponentSearchProps {
8 | value: string | null;
9 | setValue(pattern: string): void;
10 | }
11 |
12 | interface SearchInputProps {
13 | value: string;
14 | setValue(pattern: string): void;
15 | }
16 |
17 | const SearchInput = React.forwardRef(
18 | ({ value, setValue }: SearchInputProps, inputRef) => {
19 | const handleInput = (event: React.ChangeEvent) => {
20 | setValue(event.target.value);
21 | };
22 | const handleKeyDown = (event: React.KeyboardEvent) => {
23 | switch (event.code) {
24 | case "Escape":
25 | setValue("");
26 | break;
27 | }
28 | };
29 |
30 | return (
31 |
38 | );
39 | }
40 | );
41 | SearchInput.displayName = "SearchInput";
42 |
43 | const ComponentSearch = ({ value, setValue }: ComponentSearchProps) => {
44 | const inputRef = React.useRef(null);
45 |
46 | return (
47 |
48 | {SearchIcon}
49 |
50 | {value && (
51 | <>
52 |
53 |
62 |
63 | >
64 | )}
65 |
66 | );
67 | };
68 |
69 | export default ComponentSearch;
70 |
--------------------------------------------------------------------------------
/src/ui/pages/components/Toolbar.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lahmatiy/react-render-tracker/76ccd0257495eba74a4d8417b2e93b05dcb595ed/src/ui/pages/components/Toolbar.css
--------------------------------------------------------------------------------
/src/ui/pages/components/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import ButtonToggle from "../../components/common/ButtonToggle";
3 | import { useEventsContext } from "../../utils/events";
4 | import {
5 | ClearEventLog,
6 | Pause,
7 | Play,
8 | BreakRefs,
9 | ExposeToGlobal,
10 | } from "../../components/common/icons";
11 | import { FeatureMemLeaks } from "../../../common/constants";
12 | import { useMemoryLeaks } from "../../utils/memory-leaks";
13 | import { useFiberMaps } from "../../utils/fiber-maps";
14 | import ComponentSearch from "./ComponentSearch";
15 |
16 | interface ToolbarProps {
17 | filter: string;
18 | setFilter: (filter: string) => void;
19 | }
20 |
21 | const Toolbar = ({ filter, setFilter }: ToolbarProps) => {
22 | const { leakedFibers } = useFiberMaps();
23 | const { clearAllEvents, paused, setPaused } = useEventsContext();
24 | const { breakLeakedObjectRefs, exposeLeakedObjectsToGlobal } =
25 | useMemoryLeaks();
26 |
27 | return (
28 |
29 |
30 |
31 | {FeatureMemLeaks && (
32 | <>
33 |
40 | exposeLeakedObjectsToGlobal([...leakedFibers])}
43 | tooltip={
44 | "Store potential leaked objects as global variable.\n\nThis allows to investigate retainers in a heap snapshot."
45 | }
46 | />
47 |
48 | >
49 | )}
50 |
51 |
56 | setPaused(!paused)}
60 | tooltip={paused ? "Resume event loading" : "Pause event loading"}
61 | />
62 |
63 |
64 | );
65 | };
66 |
67 | const ToolbarMemo = React.memo(Toolbar);
68 | ToolbarMemo.displayName = "Toolbar";
69 |
70 | export default ToolbarMemo;
71 |
--------------------------------------------------------------------------------
/src/ui/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { CommitsPage, CommitsPageBadge } from "./Commits";
2 | import { FeatureCommits, FeatureMemLeaks } from "../../common/constants";
3 | import { ComponentsTreePage } from "./ComponentsTree";
4 | import { MaybeLeaksPage, MaybeLeaksPageBadge } from "./MaybeLeaks";
5 | import { ComponentsPage, ComponentsPageBadge } from "./Components";
6 |
7 | export const enum AppPage {
8 | ComponentTree = "component-tree",
9 | Components = "components",
10 | Commits = "commits",
11 | MaybeLeaks = "maybe-leaks",
12 | }
13 |
14 | export type AppPageConfig = {
15 | id: AppPage;
16 | title: string;
17 | disabled?: boolean;
18 | content: React.FunctionComponent;
19 | badge?: React.FunctionComponent;
20 | };
21 |
22 | export const pages: Record = {
23 | [AppPage.ComponentTree]: {
24 | id: AppPage.ComponentTree,
25 | title: "Component tree",
26 | content: ComponentsTreePage,
27 | },
28 | [AppPage.Components]: {
29 | id: AppPage.Components,
30 | title: "Component stats",
31 | content: ComponentsPage,
32 | badge: ComponentsPageBadge,
33 | },
34 | [AppPage.Commits]: {
35 | id: AppPage.Commits,
36 | title: "Commits",
37 | disabled: !FeatureCommits,
38 | content: CommitsPage,
39 | badge: CommitsPageBadge,
40 | },
41 | [AppPage.MaybeLeaks]: {
42 | id: AppPage.MaybeLeaks,
43 | title: "Memory leaks",
44 | disabled: !FeatureMemLeaks,
45 | content: MaybeLeaksPage,
46 | badge: MaybeLeaksPageBadge,
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/src/ui/pages/maybe-leaks/Fiber.css:
--------------------------------------------------------------------------------
1 | .maybe-leaks-page-fiber {
2 | padding-left: 8px;
3 | border: solid transparent;
4 | border-width: 1px 0;
5 | background-color: white;
6 | font-size: 12px;
7 | line-height: 20px;
8 | cursor: pointer;
9 | }
10 |
11 | .maybe-leaks-page-fiber:hover {
12 | background-color: #eee;
13 | }
14 | .maybe-leaks-page-fiber:hover:not(.selected) {
15 | border-color: #e6e6e6;
16 | }
17 | .maybe-leaks-page-fiber.selected {
18 | background-color: #dde8f4;
19 | border-color: #cfdeef;
20 | cursor: default;
21 | }
22 | .maybe-leaks-page-fiber.selected.render-root {
23 | background-color: #deebf7;
24 | }
25 |
26 | .maybe-leaks-page-fiber__content {
27 | display: inline-block;
28 | padding-left: 19px;
29 | padding-right: 4px;
30 | scroll-margin-top: var(--scroll-margin-top, 0px);
31 | scroll-margin-left: var(--scroll-margin-left, 0px);
32 | white-space: nowrap;
33 | }
34 |
35 | .maybe-leaks-page-fiber__name {
36 | color: #666;
37 | }
38 |
39 | .maybe-leaks-page-fiber__content .fiber-maybe-leak {
40 | margin-left: 2px;
41 | border: 1px solid rgba(255, 255, 255, 0.65);
42 | }
43 |
--------------------------------------------------------------------------------
/src/ui/pages/maybe-leaks/FiberGroup.css:
--------------------------------------------------------------------------------
1 | .maybe-leaks__group-header {
2 | --scroll-margin-top: 0px;
3 |
4 | position: sticky;
5 | top: -1px;
6 | z-index: 2;
7 | margin-top: -1px;
8 | border: solid transparent;
9 | border-width: 1px 0;
10 | border-color: #ddd;
11 | background-color: #f4f4f4;
12 | padding-left: 19px;
13 | font-size: 12px;
14 | white-space: nowrap;
15 | cursor: pointer;
16 | }
17 | .maybe-leaks__group-header:hover {
18 | background-color: #eee;
19 | }
20 |
21 | .maybe-leaks__group-header__expose-button {
22 | margin: -2px 18px -2px -20px;
23 | border: none;
24 | border-right: 1px solid #e0e0e0;
25 | background: rgba(0, 0, 0, 0.035) url(../../images/expose-to-global.svg);
26 | background-size: 10px;
27 | background-repeat: no-repeat;
28 | background-position: center;
29 | height: 23px;
30 | vertical-align: middle;
31 | aspect-ratio: 1/1;
32 | opacity: 0.75;
33 | }
34 | .maybe-leaks__group-header__expose-button:hover {
35 | opacity: 1;
36 | cursor: pointer;
37 | }
38 |
39 | .maybe-leaks__group-header-count {
40 | margin-left: 1ex;
41 | margin-right: 1ex;
42 | color: #b69815;
43 | }
44 | .maybe-leaks__group-header_secondary-count {
45 | color: #666;
46 | }
47 | .maybe-leaks__group-header_no-collected {
48 | color: #aaa;
49 | }
50 |
51 | .maybe-leaks__group-fibers {
52 | padding: 1px 0 4px;
53 | }
54 | .maybe-leaks__group-fibers__show-collected {
55 | padding: 1px 8px 1px 27px;
56 | font-size: 12px;
57 | text-decoration: underline;
58 | color: #888;
59 | }
60 | .maybe-leaks__group-fibers__show-collected:hover {
61 | background-color: #eee;
62 | color: #666;
63 | cursor: pointer;
64 | }
65 |
--------------------------------------------------------------------------------
/src/ui/pages/maybe-leaks/LeaksList.css:
--------------------------------------------------------------------------------
1 | .app-page-maybe-leaks .no-leaks {
2 | position: relative;
3 | grid-area: page;
4 | padding: 12px 16px;
5 | font-size: 13px;
6 | color: #888;
7 | }
8 |
--------------------------------------------------------------------------------
/src/ui/pages/maybe-leaks/LeaksList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useFiberMaps, useLeakedFibers } from "../../utils/fiber-maps";
3 | import { MessageFiber } from "../../types";
4 | import { FiberGroup } from "./FiberGroup";
5 |
6 | function LeaksList() {
7 | const leakedFibers = useLeakedFibers();
8 | const { fiberById } = useFiberMaps();
9 |
10 | if (!leakedFibers.length) {
11 | return (
12 | No potential leaked components detected
13 | );
14 | }
15 |
16 | const types = new Map();
17 | const typeNames = new Set();
18 |
19 | for (const fiberId of leakedFibers) {
20 | const fiber = fiberById.get(fiberId);
21 |
22 | if (!fiber) {
23 | continue;
24 | }
25 |
26 | const fibers = types.get(fiber.typeId);
27 |
28 | if (!fibers) {
29 | types.set(fiber.typeId, [fiber]);
30 | typeNames.add(fiber.displayName);
31 | } else {
32 | fibers.push(fiber);
33 | }
34 | }
35 |
36 | return (
37 | <>
38 | {[...types.values()]
39 | .sort((a, b) => (a[0].displayName < b[0].displayName ? -1 : 1))
40 | .map(fibers => (
41 |
47 | ))}
48 | >
49 | );
50 | }
51 |
52 | const LeaksListMemo = React.memo(LeaksList);
53 | LeaksListMemo.displayName = "LeaksList";
54 |
55 | export { LeaksListMemo as LeaksList };
56 |
--------------------------------------------------------------------------------
/src/ui/pages/maybe-leaks/Toolbar.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lahmatiy/react-render-tracker/76ccd0257495eba74a4d8417b2e93b05dcb595ed/src/ui/pages/maybe-leaks/Toolbar.css
--------------------------------------------------------------------------------
/src/ui/rempl-subscriber.ts:
--------------------------------------------------------------------------------
1 | import { getSubscriber } from "rempl";
2 |
3 | export const remoteSubscriber = getSubscriber();
4 |
--------------------------------------------------------------------------------
/src/ui/types.ts:
--------------------------------------------------------------------------------
1 | export * from "common-types";
2 | export * from "../common/consumer-types";
3 |
--------------------------------------------------------------------------------
/src/ui/utils/duration.ts:
--------------------------------------------------------------------------------
1 | export function formatDuration(duration: number) {
2 | if (duration >= 100) {
3 | duration = Math.round(duration);
4 |
5 | if (duration >= 10000) {
6 | return (duration / 1000).toFixed(1) + "s";
7 | }
8 |
9 | return duration + "ms";
10 | }
11 |
12 | return duration.toFixed(1) + "ms";
13 | }
14 |
--------------------------------------------------------------------------------
/src/ui/utils/fiber.ts:
--------------------------------------------------------------------------------
1 | import { FiberType } from "common-types";
2 | import {
3 | ElementTypeHostComponent,
4 | ElementTypeHostPortal,
5 | ElementTypeHostText,
6 | } from "../../common/constants";
7 |
8 | export function isHostType(type: FiberType) {
9 | return (
10 | type === ElementTypeHostComponent ||
11 | type === ElementTypeHostText ||
12 | type === ElementTypeHostPortal
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/utils/memory-leaks.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { remoteSubscriber } from "../rempl-subscriber";
3 | import { ExposedToGlobalLeaksState } from "rempl";
4 |
5 | interface MemoryLeaksContext {
6 | breakLeakedObjectRefs: () => void;
7 | exposeLeakedObjectsToGlobal: (fiberIds?: number[]) => void;
8 | cancelExposingLeakedObjectsToGlobal: () => void;
9 | exposedLeaks: ExposedToGlobalLeaksState;
10 | }
11 |
12 | const MemoryLeaksContext = React.createContext({
13 | breakLeakedObjectRefs: () => undefined,
14 | exposeLeakedObjectsToGlobal: () => undefined,
15 | cancelExposingLeakedObjectsToGlobal: () => undefined,
16 | exposedLeaks: null,
17 | });
18 | export const useMemoryLeaks = () => React.useContext(MemoryLeaksContext);
19 | export function MemoryLeaksContextProvider({
20 | children,
21 | }: {
22 | children: React.ReactNode;
23 | }) {
24 | const ns = remoteSubscriber.ns("memory-leaks");
25 | const [exposedLeaksState, setExposedLeaksState] =
26 | React.useState(null);
27 | const value = React.useMemo(() => {
28 | return {
29 | exposedLeaks: exposedLeaksState,
30 | breakLeakedObjectRefs() {
31 | ns.callRemote("breakLeakedObjectRefs");
32 | },
33 | exposeLeakedObjectsToGlobal(fiberIds?: number[]) {
34 | ns.callRemote("exposeLeakedObjectsToGlobal", fiberIds);
35 | },
36 | cancelExposingLeakedObjectsToGlobal() {
37 | ns.callRemote("cancelExposingLeakedObjectsToGlobal");
38 | },
39 | };
40 | }, [exposedLeaksState]);
41 |
42 | React.useEffect(
43 | () =>
44 | remoteSubscriber
45 | .ns("memory-leaks")
46 | .subscribe(state => setExposedLeaksState(state)),
47 | []
48 | );
49 |
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/ui/utils/open-file.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { OpenSourceSettings } from "rempl";
3 | import { remoteSubscriber } from "../rempl-subscriber";
4 | import { LocType } from "../types";
5 |
6 | interface OpenFileContext {
7 | anchorAttrs(
8 | loc: string,
9 | type?: LocType
10 | ):
11 | | {
12 | href: string;
13 | onClick?: (e: React.MouseEvent) => void;
14 | }
15 | | undefined;
16 | available: boolean;
17 | }
18 |
19 | const OpenFileContext = React.createContext({
20 | anchorAttrs: () => undefined,
21 | available: false,
22 | });
23 | export const useOpenFile = () => React.useContext(OpenFileContext);
24 | export function OpenFileContextProvider({
25 | children,
26 | }: {
27 | children: React.ReactNode;
28 | }) {
29 | const [settings, setSettings] = React.useState(null);
30 | const value = React.useMemo(() => {
31 | return {
32 | available: Boolean(settings),
33 | anchorAttrs(loc, type = "default") {
34 | if (settings) {
35 | try {
36 | const [, path = "", line = "1", column = "1"] =
37 | loc.match(/^(.+?)(?::(\d+)(?::(\d+))?)?$/) || [];
38 | const basedir =
39 | type === "jsx" ? settings.basedirJsx : settings.basedir;
40 | const filepath =
41 | settings.projectRoot +
42 | new URL(path, "http://test" + basedir).pathname;
43 | const resolvedLoc = `${filepath}:${line}:${column}`;
44 | const values = {
45 | loc: resolvedLoc,
46 | file: resolvedLoc,
47 | filepath,
48 | line,
49 | column,
50 | line0: String(parseInt(line, 10) - 1),
51 | column0: String(parseInt(column, 10) - 1),
52 | };
53 |
54 | const href = settings.pattern.replace(
55 | /\[([a-z]+\d?)\]/g,
56 | (m, key: keyof typeof values) =>
57 | values.hasOwnProperty(key) ? values[key] : m
58 | );
59 |
60 | if (/^https?:\/\//.test(href)) {
61 | return {
62 | href,
63 | onClick(e: React.MouseEvent) {
64 | e.preventDefault();
65 | fetch(href);
66 | },
67 | };
68 | }
69 |
70 | return { href };
71 | } catch (e) {}
72 | }
73 |
74 | return undefined;
75 | },
76 | };
77 | }, [settings]);
78 |
79 | React.useEffect(() => {
80 | remoteSubscriber.ns("open-source-settings").subscribe(settings => {
81 | setSettings(settings);
82 | });
83 | }, []);
84 |
85 | return (
86 |
87 | {children}
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/ui/utils/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { AppPage } from "../pages";
3 |
4 | interface PageState {
5 | currentPage: AppPage;
6 | openPage(page: AppPage): void;
7 | }
8 |
9 | const PageContext = React.createContext({} as any);
10 | export const usePageContext = () => React.useContext(PageContext);
11 | export const PageContextProvider = ({
12 | children,
13 | }: {
14 | children: React.ReactNode;
15 | }) => {
16 | const [page, setPage] = React.useState(AppPage.ComponentTree);
17 | const value = React.useMemo(
18 | () => ({
19 | currentPage: page,
20 | openPage: (page: AppPage) => setPage(page),
21 | }),
22 | [page]
23 | );
24 |
25 | return {children};
26 | };
27 |
--------------------------------------------------------------------------------
/src/ui/utils/pinned.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { notify, subscribe, useSubscription } from "./subscription";
3 |
4 | type idChangeCallback = (id: number) => void;
5 | interface Pinned {
6 | pinnedId: number;
7 | pin: (nextPinnedId: number) => void;
8 | subscribe: (fn: (value: number) => void) => () => void;
9 | }
10 |
11 | const PinnedContext = React.createContext({} as any);
12 | export const usePinnedContext = () => React.useContext(PinnedContext);
13 | export const PinnedContextProvider = ({
14 | children,
15 | }: {
16 | children: React.ReactNode;
17 | }) => {
18 | const value: Pinned = React.useMemo(() => {
19 | let pinnedId = 0;
20 | const subscriptions = new Set<{ fn: idChangeCallback }>();
21 | const pin: Pinned["pin"] = nextPinnedId => {
22 | const prevPinnedId = pinnedId;
23 |
24 | if (nextPinnedId === prevPinnedId) {
25 | return;
26 | }
27 |
28 | pinnedId = nextPinnedId;
29 | notify(subscriptions, pinnedId);
30 | };
31 |
32 | return {
33 | get pinnedId() {
34 | return pinnedId;
35 | },
36 | set pinnedId(id) {
37 | pin(id);
38 | },
39 | pin,
40 | subscribe(fn) {
41 | return subscribe(subscriptions, fn);
42 | },
43 | };
44 | }, []);
45 |
46 | return (
47 | {children}
48 | );
49 | };
50 |
51 | export const PinnedIdConsumer = ({
52 | children,
53 | }: {
54 | children: (pinnedId: number) => JSX.Element;
55 | }) => {
56 | const { pinnedId, subscribe } = usePinnedContext();
57 | const [state, setState] = React.useState(pinnedId);
58 |
59 | useSubscription(() => subscribe(setState));
60 |
61 | return children(state);
62 | };
63 |
64 | export const usePinnedId = () => {
65 | const { pinnedId, subscribe, pin } = usePinnedContext();
66 | const [state, setState] = React.useState(pinnedId);
67 |
68 | useSubscription(() => subscribe(setState));
69 |
70 | return { pinnedId: state, pin };
71 | };
72 |
--------------------------------------------------------------------------------
/src/ui/utils/react-renderers.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { remoteSubscriber } from "../rempl-subscriber";
3 | import { ReactRendererInfo, ReactUnsupportedRendererInfo } from "../types";
4 | import { RemoteProtocol } from "rempl";
5 |
6 | type ReactRenderersContext = {
7 | renderers: ReactRendererInfo[];
8 | unsupportedRenderers: ReactUnsupportedRendererInfo[];
9 | selected: ReactRendererInfo | null;
10 | };
11 |
12 | const ReactRenderersContext = React.createContext({
13 | renderers: [],
14 | unsupportedRenderers: [],
15 | selected: null,
16 | });
17 | export const useReactRenderers = () => React.useContext(ReactRenderersContext);
18 | export function ReactRenderersContextProvider({
19 | children,
20 | }: {
21 | children: React.ReactNode;
22 | }) {
23 | const [{ renderers, unsupportedRenderers }, setRenderers] = React.useState<
24 | RemoteProtocol["react-renderers"]["data"]
25 | >({ renderers: [], unsupportedRenderers: [] });
26 | const value: ReactRenderersContext = React.useMemo(
27 | () => ({
28 | renderers,
29 | unsupportedRenderers,
30 | selected: renderers[0] || null,
31 | }),
32 | [renderers, unsupportedRenderers]
33 | );
34 |
35 | React.useEffect(
36 | () =>
37 | remoteSubscriber
38 | .ns("react-renderers")
39 | .subscribe(({ renderers, unsupportedRenderers }) => {
40 | if (renderers.length || unsupportedRenderers.length) {
41 | setRenderers({ renderers, unsupportedRenderers });
42 | }
43 | }),
44 | []
45 | );
46 |
47 | return (
48 |
49 | {children}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/ui/utils/tree.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSubscription } from "./subscription";
3 | import { Tree } from "../../data";
4 |
5 | export function useTreeUpdateSubscription(tree: Tree) {
6 | const [state, setState] = React.useState(0);
7 |
8 | useSubscription(
9 | () => tree.subscribe(() => setState(state => state + 1)),
10 | [tree]
11 | );
12 |
13 | return state;
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "ES2020",
5 | "moduleResolution": "node",
6 | "lib": ["ESNext", "DOM", "DOM.iterable"],
7 | "jsx": "react",
8 | "sourceMap": true,
9 | "esModuleInterop": true,
10 | "allowJs": true,
11 | "strict": true,
12 | "noImplicitAny": true,
13 | "noImplicitReturns": true,
14 | "resolveJsonModule": true,
15 | "declaration": true
16 | },
17 | "exclude": ["node_modules", "dist"]
18 | }
19 |
--------------------------------------------------------------------------------