Repository: aidenybai/react-scan
Branch: main
Commit: b5be21653951
Files: 238
Total size: 1.5 MB
Directory structure:
gitextract_xag52wf6/
├── .changeset/
│ ├── README.md
│ └── config.json
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ └── workflows/
│ ├── build-extension.yml
│ └── pkg-pr-new.yaml
├── .gitignore
├── .npmrc
├── .oxlintrc.json
├── .vscode/
│ └── settings.json
├── AGENTS.md
├── BROWSER_EXTENSION_GUIDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── bin/
│ ├── generate-certs.sh
│ └── serve-scan.sh
├── docs/
│ └── installation/
│ ├── astro.md
│ ├── cdn.md
│ ├── create-react-app.md
│ ├── next-js-app-router.md
│ ├── next-js-page-router.md
│ ├── parcel.md
│ ├── react-router.md
│ ├── remix.md
│ ├── rsbuild.md
│ ├── tanstack-start.md
│ └── vite.md
├── e2e/
│ ├── helpers.ts
│ ├── inspector.spec.ts
│ ├── notifications.spec.ts
│ ├── outlines.spec.ts
│ └── toolbar.spec.ts
├── kitchen-sink/
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src/
│ │ ├── examples/
│ │ │ ├── e2e-fixture/
│ │ │ │ └── index.tsx
│ │ │ ├── sierpinski/
│ │ │ │ ├── index.tsx
│ │ │ │ └── styles.css
│ │ │ └── todo-list/
│ │ │ ├── index.tsx
│ │ │ └── styles.css
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── main.css
│ │ └── main.tsx
│ ├── tailwind.config.mjs
│ ├── tsconfig.json
│ └── vite.config.ts
├── package.json
├── packages/
│ ├── extension/
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── assets/
│ │ │ │ └── css/
│ │ │ │ └── no-react.css
│ │ │ ├── background/
│ │ │ │ ├── icon.ts
│ │ │ │ └── index.ts
│ │ │ ├── content/
│ │ │ │ └── index.ts
│ │ │ ├── inject/
│ │ │ │ ├── index.ts
│ │ │ │ ├── notification.ts
│ │ │ │ └── react-scan.ts
│ │ │ ├── manifest.chrome.json
│ │ │ ├── manifest.firefox.json
│ │ │ ├── types/
│ │ │ │ ├── global.d.ts
│ │ │ │ └── messages.ts
│ │ │ ├── utils/
│ │ │ │ ├── constants.ts
│ │ │ │ └── helpers.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── scan/
│ │ ├── .gitignore
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── auto.d.ts
│ │ ├── bin/
│ │ │ └── cli.js
│ │ ├── global.d.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── postcss.rem2px.mjs
│ │ ├── scripts/
│ │ │ └── bump-version.js
│ │ ├── src/
│ │ │ ├── auto.ts
│ │ │ ├── cli-utils.mts
│ │ │ ├── cli-utils.test.mts
│ │ │ ├── cli.mts
│ │ │ ├── core/
│ │ │ │ ├── all-environments.ts
│ │ │ │ ├── fast-serialize.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── instrumentation.ts
│ │ │ │ ├── notifications/
│ │ │ │ │ ├── event-tracking.ts
│ │ │ │ │ ├── interaction-store.ts
│ │ │ │ │ ├── outline-overlay.ts
│ │ │ │ │ ├── performance-store.ts
│ │ │ │ │ ├── performance-utils.ts
│ │ │ │ │ ├── performance.ts
│ │ │ │ │ └── types.ts
│ │ │ │ └── utils.ts
│ │ │ ├── index.ts
│ │ │ ├── install-hook.ts
│ │ │ ├── monitoring/
│ │ │ │ └── next.ts
│ │ │ ├── new-outlines/
│ │ │ │ ├── canvas.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── offscreen-canvas.worker.ts
│ │ │ │ └── types.ts
│ │ │ ├── polyfills.ts
│ │ │ ├── react-component-name/
│ │ │ │ ├── __tests__/
│ │ │ │ │ ├── arrow-function.test.ts
│ │ │ │ │ ├── complex-patterns.test.ts
│ │ │ │ │ ├── function-declarations.test.ts
│ │ │ │ │ ├── general-cases.test.ts
│ │ │ │ │ ├── react-patterns.test.ts
│ │ │ │ │ ├── ts-patterns.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── astro.ts
│ │ │ │ ├── babel/
│ │ │ │ │ ├── get-descriptive-name.ts
│ │ │ │ │ ├── get-root-statement-path.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── is-componentish-name.ts
│ │ │ │ │ ├── is-nested-expression.ts
│ │ │ │ │ ├── is-path-valid.ts
│ │ │ │ │ ├── is-statement-top-level.ts
│ │ │ │ │ ├── path-references-import.ts
│ │ │ │ │ └── unwrap.ts
│ │ │ │ ├── core/
│ │ │ │ │ └── options.ts
│ │ │ │ ├── esbuild.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── rolldown.ts
│ │ │ │ ├── rollup.ts
│ │ │ │ ├── rspack.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vite.ts
│ │ │ │ └── webpack.ts
│ │ │ ├── types.d.ts
│ │ │ ├── types.ts
│ │ │ ├── web/
│ │ │ │ ├── assets/
│ │ │ │ │ └── css/
│ │ │ │ │ └── styles.tailwind.css
│ │ │ │ ├── components/
│ │ │ │ │ ├── copy-to-clipboard/
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── icon/
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── slider/
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── sticky-section/
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── svg-sprite/
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── toggle/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── hooks/
│ │ │ │ │ ├── use-delayed-value.ts
│ │ │ │ │ ├── use-merged-refs.ts
│ │ │ │ │ └── use-virtual-list.ts
│ │ │ │ ├── state.ts
│ │ │ │ ├── toolbar.tsx
│ │ │ │ ├── utils/
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── create-store.ts
│ │ │ │ │ ├── geiger.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── log.ts
│ │ │ │ │ ├── pin.ts
│ │ │ │ │ └── preact/
│ │ │ │ │ └── constant.ts
│ │ │ │ ├── views/
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── inspector/
│ │ │ │ │ │ ├── components-tree/
│ │ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ │ └── state.ts
│ │ │ │ │ │ ├── diff-value.tsx
│ │ │ │ │ │ ├── flash-overlay.ts
│ │ │ │ │ │ ├── header.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── logging.ts
│ │ │ │ │ │ ├── overlay/
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ ├── properties.tsx
│ │ │ │ │ │ ├── states.ts
│ │ │ │ │ │ ├── timeline/
│ │ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ │ └── utils.ts
│ │ │ │ │ │ ├── utils.ts
│ │ │ │ │ │ ├── what-changed.tsx
│ │ │ │ │ │ └── whats-changed/
│ │ │ │ │ │ └── use-change-store.ts
│ │ │ │ │ ├── notifications/
│ │ │ │ │ │ ├── collapsed-event.tsx
│ │ │ │ │ │ ├── data.ts
│ │ │ │ │ │ ├── details-routes.tsx
│ │ │ │ │ │ ├── icons.tsx
│ │ │ │ │ │ ├── notification-header.tsx
│ │ │ │ │ │ ├── notification-tabs.tsx
│ │ │ │ │ │ ├── notifications.tsx
│ │ │ │ │ │ ├── optimize.tsx
│ │ │ │ │ │ ├── other-visualization.tsx
│ │ │ │ │ │ ├── popover.tsx
│ │ │ │ │ │ ├── render-bar-chart.tsx
│ │ │ │ │ │ ├── render-explanation.tsx
│ │ │ │ │ │ └── slowdown-history.tsx
│ │ │ │ │ ├── settings/
│ │ │ │ │ │ └── header.tsx
│ │ │ │ │ └── toolbar/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── widget/
│ │ │ │ ├── fps-meter.tsx
│ │ │ │ ├── header.tsx
│ │ │ │ ├── helpers.ts
│ │ │ │ ├── index.tsx
│ │ │ │ ├── resize-handle.tsx
│ │ │ │ └── types.ts
│ │ │ └── worker-shim.ts
│ │ ├── tailwind.config.mjs
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ ├── vite.config.mts
│ │ └── worker-plugin.ts
│ ├── vite-plugin-react-scan/
│ │ ├── .npmignore
│ │ ├── CHANGELOG.md
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── global.d.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── website/
│ ├── .gitignore
│ ├── .oxlintrc.json
│ ├── AGENTS.md
│ ├── README.md
│ ├── app/
│ │ ├── api/
│ │ │ └── waitlist/
│ │ │ └── route.ts
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── react-scan.ts
│ │ └── replay/
│ │ └── page.tsx
│ ├── components/
│ │ ├── cli.tsx
│ │ ├── code.tsx
│ │ ├── companies.tsx
│ │ ├── counter.tsx
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ ├── icons/
│ │ │ ├── icon-discord.tsx
│ │ │ ├── icon-github.tsx
│ │ │ └── types.ts
│ │ ├── install-guide.tsx
│ │ ├── test-data-types.tsx
│ │ └── todo-demo.tsx
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public/
│ │ └── auto.global.js
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── playwright.config.ts
├── pnpm-workspace.yaml
├── scripts/
│ ├── build-worker.ts
│ ├── bump-version.js
│ ├── version-warning.mjs
│ └── workspace.mjs
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .changeset/README.md
================================================
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
================================================
FILE: .changeset/config.json
================================================
{
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [["react-scan", "@react-scan/extension"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@react-scan/website"]
}
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at aiden.bai05@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: .github/workflows/build-extension.yml
================================================
name: Build Extension
on:
push:
branches:
- main
paths:
- 'packages/extension/**'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 9.x
- name: Install dependencies
run: pnpm install
- name: Get package info
id: package
run: |
echo "name=$(node -p "require('./packages/extension/package.json').name")" >> $GITHUB_OUTPUT
echo "version=$(node -p "require('./packages/extension/package.json').version")" >> $GITHUB_OUTPUT
- name: Build extensions
run: |
pnpm build
cd packages/extension
rm -rf build
pnpm pack:all
- name: Commit changes
if: github.ref == 'refs/heads/main'
run: |
git checkout main
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -f packages/extension/build/*.zip
git diff --staged --quiet || (git commit -m "chore: update extension builds [skip ci]" && git push)
================================================
FILE: .github/workflows/pkg-pr-new.yaml
================================================
name: Publish Any Commit
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.1.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile --strict-peer-dependencies=false
- name: Build
run: |
cd packages/scan
NODE_ENV=production pnpm build
env:
NODE_ENV: production
- name: Publish NPM Package to pkg-pr-new
run: pnpx pkg-pr-new publish ./packages/scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .gitignore
================================================
node_modules
.DS_Store
.env
dist
**/*.tgz
*.log
build
!packages/extension/build/
playgrounds
# SSL Certificates
bin/certs/*.pem
bin/certs/*.key
.cursor
# Playwright
test-results/
playwright-report/
================================================
FILE: .npmrc
================================================
prefer-workspace-packages=true
link-workspace-packages=true
================================================
FILE: .oxlintrc.json
================================================
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "import"],
"ignorePatterns": [
"dist",
"build",
"node_modules",
"**/*.css",
"**/*.astro"
],
"categories": {},
"rules": {
"no-unused-vars": [
"warn",
{
"vars": "all",
"args": "all",
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrors": "none"
}
],
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-console": "warn",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "warn",
"react/no-danger": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
================================================
FILE: .vscode/settings.json
================================================
{
"editor.formatOnSave": true,
"css.lint.unknownAtRules": "ignore",
"oxc.lint.enable": true,
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib"
}
================================================
FILE: AGENTS.md
================================================
## General Rules
- MUST: Use TypeScript interfaces over types.
- MUST: Keep all types in the global scope.
- MUST: Use arrow functions over function declarations
- MUST: Never comment unless absolutely necessary.
- If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack
- MUST: Use kebab-case for files
- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).
- Example: for .map(), you can use `innerX` instead of `x`
- Example: instead of `moved` use `didPositionChange`
- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive.
- MUST: Do not type cast ("as") unless absolutely necessary
- MUST: Remove unused code and don't repeat yourself.
- MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution.
- MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`).
- MUST: Put small, focused utility functions in `utils/` with one utility per file.
- MUST: Use Boolean over !!.
## Testing
Run checks always before committing with:
```bash
pnpm build
pnpm lint
pnpm format
```
================================================
FILE: BROWSER_EXTENSION_GUIDE.md
================================================
# Browser Extension Installation Guide
> [!NOTE]
> The React Scan browser extension currently uses `react-scan@0.4.3`
## Chrome
> You can download the Chrome browser extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/react-scan/anmmhkomejbdklkhoiloeaehppaffmdf). Below is a guide to installing the extension manually.
1. Download the [`chrome-extension-v1.1.4.zip`](https://github.com/aidenybai/react-scan/tree/main/packages/extension/build) file.
2. Unzip the file.
3. Open Chrome and navigate to `chrome://extensions/`.
4. Enable "Developer mode" if it is not already enabled.
5. Click "Load unpacked" and select the unzipped folder (or drag the folder into the page).
## Firefox
> React Scan's Browser extension is still pending approvals from Firefox Add-ons. Below is a guide to installing the extension manually.
1. Download the [`firefox-extension-v1.1.4.zip`](https://github.com/aidenybai/react-scan/tree/main/packages/extension/build) file.
2. Unzip the file.
3. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`.
4. Click "Load Temporary Add-on..."
5. Select `manifest.json` from the unzipped folder
## Brave
> React Scan's Browser extension is still pending approvals from Brave Browser. Below is a guide to installing the extension manually.
1. Download the [`brave-extension-v1.1.4.zip`](https://github.com/aidenybai/react-scan/tree/main/packages/extension/build) file.
2. Unzip the file.
3. Open Brave and navigate to `brave://extensions`.
4. Click "Load unpacked" and select the unzipped folder (or drag the folder into the page).
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to React Scan
First off, thanks for taking the time to contribute! ❤️
## Table of Contents
- [Contributing to React Scan](#contributing-to-react-scan)
- [Table of Contents](#table-of-contents)
- [Project Structure](#project-structure)
- [Development Setup](#development-setup)
- [Contributing Guidelines](#contributing-guidelines)
- [Commits](#commits)
- [Pull Request Process](#pull-request-process)
- [Development Workflow](#development-workflow)
- [Getting Help](#getting-help)
## Project Structure
This is a monorepo containing several packages:
- `packages/scan` - Core React Scan package
- `packages/vite-plugin-react-scan` - Vite plugin for React Scan
- `packages/extension` - VS Code extension
## Development Setup
1. **Clone and Install**
```bash
git clone https://github.com/aidenybai/react-scan.git
cd react-scan
pnpm install
```
2. **Build all packages**
```bash
pnpm build
```
3. **Testing React Scan**
```bash
cd packages/scan
pnpm build:copy
```
- This will build the package and then copy it to your clipboard as an IIFE (immedietely invoked function expression). This will allow you to paste it into the browser console to test it on any website
https://github.com/user-attachments/assets/f279e664-479f-4e39-bff4-1bbfee30af22
## Contributing Guidelines
### Commits
We use conventional commits to ensure consistent commit messages:
- `feat:` New features
- `fix:` Bug fixes
- `docs:` Documentation changes
- `chore:` Maintenance tasks
- `test:` Adding or updating tests
- `refactor:` Code changes that neither fix bugs nor add features
Example: `fix(scan): fix a typo`
### Pull Request Process
1. Fork the repository
2. Create your feature branch (`git checkout -b feat/amazing-feature`)
3. Commit your changes using conventional commits
4. Push to your branch
5. Open a Pull Request
6. Ask for reviews (@pivanov, @RobPruzan are your friends in this journey)
### Development Workflow
1. **TypeScript**
- All code must be written in TypeScript
- Ensure strict type checking passes
- No `any` types unless absolutely necessary
2. **Code Style**
- We use Biome for formatting and linting
- Run `pnpm format` to format code
- Run `pnpm lint` to check for issues
3. **Documentation**
- Update relevant documentation
- Add JSDoc comments for public APIs
- Update README if needed
## Getting Help
- Check existing issues
- Create a new issue
⚛️ Happy coding! 🚀
================================================
FILE: LICENSE
================================================
Copyright 2025 Aiden Bai, Million Software, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# React Scan
React Scan automatically detects performance issues in your React app.
- Requires no code changes -- just drop it in
- Highlights exactly the components you need to optimize
- Always accessible through a toolbar on page
### Quick Start
```bash
npx -y react-scan@latest init
```
### [**Try out a demo! →**](https://react-scan.million.dev)
## Install
The `init` command will automatically detect your framework, install `react-scan` via npm, and set up your project.
```bash
npx -y react-scan@latest init
```
### Manual Installation
Install the package:
```bash
npm install -D react-scan
```
Then add the script tag to your app. Pick the guide for your framework:
#### Script Tag
Paste this before any scripts in your `index.html`:
```html
```
#### Next.js (App Router)
Add this inside of your `app/layout.tsx`:
```tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
{children}
);
}
```
#### Next.js (Pages Router)
Add this into your `pages/_document.tsx`:
```tsx
import { Html, Head, Main, NextScript } from "next/document";
import Script from "next/script";
export default function Document() {
return (
);
}
```
#### Vite
Example `index.html` with React Scan enabled:
```html
```
#### Remix
Add this inside your `app/root.tsx`:
```tsx
import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
export default function App() {
return (
);
}
```
### Browser Extension
Install the extension by following the guide [here](https://github.com/aidenybai/react-scan/blob/main/BROWSER_EXTENSION_GUIDE.md).
## API Reference
Options
```tsx
export interface Options {
/**
* Enable/disable scanning
* @default true
*/
enabled?: boolean;
/**
* Force React Scan to run in production (not recommended)
* @default false
*/
dangerouslyForceRunInProduction?: boolean;
/**
* Log renders to the console
* @default false
*/
log?: boolean;
/**
* Show toolbar bar
* @default true
*/
showToolbar?: boolean;
/**
* Animation speed
* @default "fast"
*/
animationSpeed?: "slow" | "fast" | "off";
onCommitStart?: () => void;
onRender?: (fiber: Fiber, renders: Array) => void;
onCommitFinish?: () => void;
}
```
- `scan(options: Options)`: Imperative API to start scanning
- `useScan(options: Options)`: Hook API to start scanning
- `setOptions(options: Options): void`: Set options at runtime
- `getOptions()`: Get the current options
- `onRender(Component, onRender: (fiber: Fiber, render: Render) => void)`: Hook into a specific component's renders
## Why React Scan?
React can be tricky to optimize.
The issue is that component props are compared by reference, not value. This is intentional -- rendering can be cheap to run.
However, this makes it easy to accidentally cause unnecessary renders, making the app slow. Even production apps with hundreds of engineers can't fully optimize their apps (see [GitHub](https://github.com/aidenybai/react-scan/blob/main/.github/assets/github.mp4), [Twitter](https://github.com/aidenybai/react-scan/blob/main/.github/assets/twitter.mp4), and [Instagram](https://github.com/aidenybai/react-scan/blob/main/.github/assets/instagram.mp4)).
```jsx
alert("hi")} style={{ color: "purple" }} />
```
React Scan helps you identify these issues by automatically detecting and highlighting renders that cause performance issues.
## Resources & Contributing
Want to try it out? Check the [demo](https://react-scan.million.dev).
Looking to contribute? Check the [Contributing Guide](https://github.com/aidenybai/react-scan/blob/main/CONTRIBUTING.md).
Want to talk to the community? Join our [Discord](https://discord.gg/X9yFbcV2rF).
Find a bug? Head to our [issue tracker](https://github.com/aidenybai/react-scan/issues).
[**→ Start contributing on GitHub**](https://github.com/aidenybai/react-scan/blob/main/CONTRIBUTING.md)
## Acknowledgments
- [React Devtools](https://react.dev/learn/react-developer-tools) for the initial idea of highlighting renders
- [Million Lint](https://million.dev) for scanning and linting approaches
- [Why Did You Render?](https://github.com/welldone-software/why-did-you-render) for the concept of detecting unnecessary renders
## License
React Scan is [MIT-licensed](LICENSE) open-source software by Aiden Bai, [Million Software, Inc.](https://million.dev), and [contributors](https://github.com/aidenybai/react-scan/graphs/contributors).
================================================
FILE: bin/generate-certs.sh
================================================
#!/bin/bash
mkdir -p bin/certs
openssl req -x509 -newkey rsa:2048 -keyout bin/certs/key.pem -out bin/certs/cert.pem -days 365 -nodes -subj "/CN=127.0.0.1"
================================================
FILE: bin/serve-scan.sh
================================================
#!/bin/bash
# Determine the directory of the script
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Default values
DEFAULT_PATH="./packages/scan/dist"
DEFAULT_PORT="4000"
DEFAULT_CERT="$SCRIPT_DIR/certs/cert.pem"
DEFAULT_KEY="$SCRIPT_DIR/certs/key.pem"
# Positional arguments
SERVE_PATH="$1" # First argument is the path
# Get optional flags
shift # Remove the first argument from the list
while [[ "$#" -gt 0 ]]; do
case $1 in
--port) PORT_ARG="$2"; shift ;;
--cert) CERT_ARG="$2"; shift ;;
--key) KEY_ARG="$2"; shift ;;
*) echo "Unknown parameter: $1" >&2; exit 1 ;;
esac
shift
done
# Use provided arguments or defaults
SERVE_PATH="${SERVE_PATH:-$DEFAULT_PATH}"
SERVE_PORT="${PORT_ARG:-$DEFAULT_PORT}"
SERVE_CERT="${CERT_ARG:-$DEFAULT_CERT}"
SERVE_KEY="${KEY_ARG:-$DEFAULT_KEY}"
# Run the server with CORS enabled
http-server "$SERVE_PATH" -p "$SERVE_PORT" --ssl --cert "$SERVE_CERT" --key "$SERVE_KEY" --cors
================================================
FILE: docs/installation/astro.md
================================================
# Astro Guide
## As a script tag
Add the script tag to your root layout.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```astro
```
## As a module import
Add the script to your root layout
```astro
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
================================================
FILE: docs/installation/cdn.md
================================================
# CDN
You can choose one of the following URLs to initialize React Scan via CDN.
## Usage
```html
```
## Available URLs
### JSDelivr
```txt
https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js
```
### UNPKG
```txt
https://unpkg.com/react-scan/dist/auto.global.js
```
================================================
FILE: docs/installation/create-react-app.md
================================================
# Create React App (CRA) Guide
## As a script tag
Add the script tag to your `index.html`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```html
```
## As a module import
In your project entrypoint (e.g. `src/index`, `src/main`):
```jsx
// src/index.jsx
// must be imported before React and React DOM
import { scan } from "react-scan";
import React from "react";
scan({
enabled: true,
});
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM) in your entire project, as it needs to hijack React DevTools before React gets to access it.
================================================
FILE: docs/installation/next-js-app-router.md
================================================
# NextJS App Router Guide
## As a script tag
Add the script tag to your `app/layout`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```jsx
// app/layout
export default function RootLayout({ children }) {
return (
{/* rest of your scripts go under */}
{children}
);
}
```
## As a module import
Create a `` client component:
```jsx
// path/to/ReactScanComponent
"use client";
// react-scan must be imported before react
import { scan } from "react-scan";
import { JSX, useEffect } from "react";
export function ReactScan(): JSX.Element {
useEffect(() => {
scan({
enabled: true,
});
}, []);
return <>>;
}
```
Import the `` component into `app/layout`:
```jsx
// app/layout
// This component must be the top-most import in this file!
import { ReactScan } from "path/to/ReactScanComponent";
// ...
export default function RootLayout({ children }) {
return (
{children}
);
}
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
================================================
FILE: docs/installation/next-js-page-router.md
================================================
# NextJS Page Router Guide
## As a script tag
Add the script tag to your `pages/_document`
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```jsx
// pages/_document
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
{/* rest of your scripts go under */}
);
}
```
## As a module import
Add the following code to your `App` component in `pages/_app`:
```jsx
// pages/_app
// react-scan must be the top-most import
import { scan } from "react-scan";
import { useEffect } from "react";
export default function App({ Component, pageProps }) {
useEffect(() => {
// Make sure to run React Scan after hydration
scan({
enabled: true,
});
}, []);
return ;
}
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
================================================
FILE: docs/installation/parcel.md
================================================
# Parcel Guide
## As a script tag
Add the script tag to your `index.html`
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```html
```
================================================
FILE: docs/installation/react-router.md
================================================
# React Router v7 Guide
## As a script tag
Add the script tag to your `Layout` component in the `app/root`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```jsx
// app/root
// ...
export function Layout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// ...
```
> [!CAUTION]
> This only works for React 19
## As an import
Add the following code to your `app/root`
```jsx
// app/root
// Must be imported before React Router
import { scan } from "react-scan";
import { Links, Meta, Scripts, ScrollRestoration } from "react-router";
import { useEffect } from "react";
export function Layout({ children }) {
useEffect(() => {
// Make sure to run react-scan only after hydration
scan({
enabled: true,
});
}, []);
return (
{children}
);
}
// ...
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM), as well as React Router, in your entire project, as it needs to hijack React DevTools before React gets to access it.
================================================
FILE: docs/installation/remix.md
================================================
# Remix Guide
## As a script tag
Add the script tag to your `` component in `app/root`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```jsx
// app/root.jsx
import {
Links,
Meta,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export function Layout({ children }: { children: React.ReactNode }) {
return (
{/* Must run before any of your scripts */}
{children}
);
}
// ...
```
> [!CAUTION]
> This only works for React 19
## As a module import
Add the following code to your `app/root`:
```jsx
// app/root.jsx
import { scan } from "react-scan"; // Must be imported before Remix
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export function Layout({ children }) {
useEffect(() => {
// Make sure to run React Scan after hydration
scan({
enabled: true,
});
}, []);
return (
{children}
);
}
export default function App() {
return ;
}
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM), as well as Remix, in your entire project, as it needs to hijack React DevTools before React gets to access it.
Alternatively you can also do the following code in `app/entry.client`:
```jsx
// app/entry.client.jsx
import { RemixBrowser } from "@remix-run/react";
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { scan } from "react-scan";
scan({
enabled: true,
});
// Hydration must happen in sync!
// startTransition(() => {
hydrateRoot(
document,
);
// });
```
> [!CAUTION]
> This only works for React 19
================================================
FILE: docs/installation/rsbuild.md
================================================
# Rsbuild Guide
## As a script tag
If you are using Rsbuild's default HTML template, add the script tag via [html.tags](https://rsbuild.dev/config/html/tags).
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```ts
// rsbuild.config.ts
export default {
html: {
tags: [
{
tag: "script",
attrs: {
src: "https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js",
},
append: false,
},
],
},
};
```
If you are using a custom HTML template, add the script tag to your template file.
```html
```
## As a module import
In your project entrypoint (e.g. `src/index`, `src/main`):
```jsx
// src/index.jsx
// must be imported before React and React DOM
import { scan } from "react-scan";
import React from "react";
scan({
enabled: true,
});
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM) in your entire project, as it needs to hijack React DevTools before React gets to access it.
================================================
FILE: docs/installation/tanstack-start.md
================================================
# TanStack Router Guide
## As a script tag
Add the script tag to your `` component at `app/routes/__root`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```jsx
// app/routes/__root
import { Meta, Scripts } from "@tanstack/start";
// ...
function RootDocument({ children }) {
return (
{children}
);
}
// ...
```
> [!CAUTION]
> This only works for React 19
## As a module import
Add the following code to your `` component at `app/routes/__root`:
```jsx
// app/routes/__root
// react-scan must be imported before React and TanStack Start
import { scan } from "react-scan";
import { Meta, Scripts } from "@tanstack/start";
import { useEffect } from "react";
// ...
function RootDocument({ children }) {
useEffect(() => {
// Make sure to run this only after hydration
scan({
enabled: true,
});
}, []);
return (
{children}
);
}
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM) in your entire project, as it needs to hijack React DevTools before React gets to access it.
Alternatively you can also do the following code in `app/client`:
```jsx
// app/client
import { scan } from "react-scan"; // must be imported before React and React DOM
import { hydrateRoot } from "react-dom/client";
import { StartClient } from "@tanstack/start";
import { createRouter } from "./router";
scan({
enabled: true,
});
const router = createRouter();
hydrateRoot(document, );
```
> [!CAUTION]
> This only works for React 19
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
================================================
FILE: docs/installation/vite.md
================================================
# Vite Guide
## As a script tag
Add the script tag to your `index.html`.
Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs.
```html
```
## As a module import
In your project entrypoint (e.g. `src/index`, `src/main`):
```jsx
// src/index
import { scan } from "react-scan"; // must be imported before React and React DOM
import React from "react";
scan({
enabled: true,
});
```
If you want react-scan to also run in production, use the react-scan/all-environments import path
```diff
- import { scan } from "react-scan";
+ import { scan } from "react-scan/all-environments";
```
> [!CAUTION]
> React Scan must be imported before React (and other React renderers like React DOM) in your entire project, as it needs to hijack React DevTools before React gets to access it.
## Vite plugin
TODO
## Preserving component names
TODO
================================================
FILE: e2e/helpers.ts
================================================
import { type Page } from '@playwright/test';
export const FIXTURE_URL = '/?example=e2e-fixture';
export async function gotoFixture(page: Page): Promise {
await page.goto(FIXTURE_URL);
await page.waitForSelector('[data-testid="heading"]', { timeout: 10_000 });
// Wait for React Scan to boot and expose __REACT_SCAN__
await page.waitForFunction(
() => typeof (window as any).__REACT_SCAN__?.ReactScanInternals !== 'undefined',
{ timeout: 15_000 },
);
// Install a render counter by patching the onRender option on the signal
await page.evaluate(() => {
(window as any).__E2E_RENDER_COUNT__ = 0;
const internals = (window as any).__REACT_SCAN__?.ReactScanInternals;
if (internals?.options) {
const prev = internals.options.value;
const prevOnRender = prev.onRender;
internals.options.value = {
...prev,
onRender: (...args: any[]) => {
(window as any).__E2E_RENDER_COUNT__++;
if (prevOnRender) prevOnRender(...args);
},
};
}
});
// Wait for initial mount renders to settle then reset
await page.waitForTimeout(500);
await page.evaluate(() => {
(window as any).__E2E_RENDER_COUNT__ = 0;
});
}
export async function getRenderCount(page: Page): Promise {
return page.evaluate(() => (window as any).__E2E_RENDER_COUNT__ ?? 0);
}
export async function waitForRenders(
page: Page,
timeout = 5000,
): Promise {
const startCount = await getRenderCount(page);
return page.evaluate(
({ start, t }) => {
return new Promise((resolve) => {
const check = () => {
const current = (window as any).__E2E_RENDER_COUNT__ ?? 0;
if (current > start) {
resolve(current - start);
return true;
}
return false;
};
if (check()) return;
const interval = setInterval(() => {
if (check()) clearInterval(interval);
}, 50);
setTimeout(() => {
clearInterval(interval);
resolve(0);
}, t);
});
},
{ start: startCount, t: timeout },
);
}
export async function isReactScanActive(page: Page): Promise {
return page.evaluate(() => {
return typeof (window as any).__REACT_SCAN__ !== 'undefined';
});
}
export async function hasShadowRoot(page: Page): Promise {
return page.evaluate(() => {
return document.getElementById('react-scan-root')?.shadowRoot != null;
});
}
================================================
FILE: e2e/inspector.spec.ts
================================================
import { test, expect } from '@playwright/test';
import { gotoFixture } from './helpers';
test.describe('Inspector', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
});
test('inspect state is available in React Scan internals', async ({ page }) => {
const hasInspectState = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
if (!scan?.ReactScanInternals?.Store) return false;
const inspectState = scan.ReactScanInternals.Store.inspectState;
return inspectState !== undefined && inspectState !== null;
});
expect(hasInspectState).toBe(true);
});
test('inspect state starts as inspect-off', async ({ page }) => {
const kind = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
return scan?.ReactScanInternals?.Store?.inspectState?.value?.kind ?? null;
});
expect(kind).toBe('inspect-off');
});
test('shadow DOM contains toolbar elements', async ({ page }) => {
const elementCount = await page.evaluate(() => {
const root = document.getElementById('react-scan-root');
return root?.shadowRoot?.querySelectorAll('*').length ?? 0;
});
expect(elementCount).toBeGreaterThan(5);
});
test('inspect state can be set programmatically', async ({ page }) => {
const activated = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
if (!scan?.ReactScanInternals?.Store?.inspectState) return false;
scan.ReactScanInternals.Store.inspectState.value = { kind: 'focused', focusedDomElement: null };
return scan.ReactScanInternals.Store.inspectState.value.kind === 'focused';
});
expect(activated).toBe(true);
});
});
================================================
FILE: e2e/notifications.spec.ts
================================================
import { test, expect } from '@playwright/test';
import { gotoFixture } from './helpers';
test.describe('Notifications', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
});
test('slow interaction is detected and recorded', async ({ page }) => {
await page.click('[data-testid="trigger-slow"]');
await page.waitForTimeout(2000);
const hasActiveStore = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
if (!scan?.ReactScanInternals?.Store) return false;
// Verify the notification system is wired up (interactionListeningForRenders is a function when active)
return typeof scan.ReactScanInternals.Store.interactionListeningForRenders === 'function';
});
expect(hasActiveStore).toBe(true);
});
test('notification system initializes with the toolbar', async ({ page }) => {
const hasCanvas = await page.evaluate(() => {
return document.querySelectorAll('canvas').length > 0;
});
expect(hasCanvas).toBe(true);
});
test('repeated slow interactions do not break the toolbar', async ({ page }) => {
for (let i = 0; i < 3; i++) {
await page.click('[data-testid="trigger-slow"]');
await page.waitForTimeout(500);
}
await page.waitForTimeout(2000);
const shadowContent = await page.evaluate(() => {
const root = document.getElementById('react-scan-root');
return root?.shadowRoot?.innerHTML ?? '';
});
expect(shadowContent.length).toBeGreaterThan(100);
});
});
================================================
FILE: e2e/outlines.spec.ts
================================================
import { test, expect, type Page } from '@playwright/test';
import { gotoFixture, getRenderCount } from './helpers';
async function clickAndCountRenders(
page: Page,
selector: string,
waitMs = 1000,
): Promise {
await page.evaluate(() => {
(window as any).__E2E_RENDER_COUNT__ = 0;
});
await page.click(selector);
await page.waitForTimeout(waitMs);
return getRenderCount(page);
}
test.describe('Render Outlines', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
});
test('state update triggers render tracking', async ({ page }) => {
const count = await clickAndCountRenders(page, '[data-testid="increment"]');
expect(count).toBeGreaterThan(0);
});
test('rapid updates produce multiple tracked renders', async ({ page }) => {
const count = await clickAndCountRenders(page, '[data-testid="trigger-rapid"]', 2000);
expect(count).toBeGreaterThan(5);
});
test('outline canvas exists on the page', async ({ page }) => {
const hasCanvas = await page.evaluate(() => {
return document.querySelectorAll('canvas').length > 0;
});
expect(hasCanvas).toBe(true);
});
test('context change triggers render tracking', async ({ page }) => {
const count = await clickAndCountRenders(page, '[data-testid="toggle-theme"]');
expect(count).toBeGreaterThan(0);
});
test('unstable props on memo components trigger render tracking', async ({ page }) => {
const count = await clickAndCountRenders(page, '[data-testid="trigger-unstable"]');
expect(count).toBeGreaterThan(0);
});
test('render count accumulates with repeated clicks', async ({ page }) => {
await page.evaluate(() => { (window as any).__E2E_RENDER_COUNT__ = 0; });
await page.click('[data-testid="increment"]');
await page.waitForTimeout(300);
const after1 = await getRenderCount(page);
await page.click('[data-testid="increment"]');
await page.waitForTimeout(300);
const after2 = await getRenderCount(page);
await page.click('[data-testid="increment"]');
await page.waitForTimeout(300);
const after3 = await getRenderCount(page);
expect(after1).toBeGreaterThan(0);
expect(after2).toBeGreaterThan(after1);
expect(after3).toBeGreaterThan(after2);
});
});
================================================
FILE: e2e/toolbar.spec.ts
================================================
import { test, expect } from '@playwright/test';
import { gotoFixture, isReactScanActive, hasShadowRoot } from './helpers';
test.describe('Toolbar', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
});
test('React Scan initializes and attaches to the page', async ({ page }) => {
const active = await isReactScanActive(page);
expect(active).toBe(true);
});
test('React Scan internals are accessible', async ({ page }) => {
const hasInternals = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
return (
scan?.ReactScanInternals !== undefined &&
scan.ReactScanInternals.options !== undefined &&
scan.ReactScanInternals.Store !== undefined
);
});
expect(hasInternals).toBe(true);
});
test('options are set correctly', async ({ page }) => {
const options = await page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
const opts = scan?.ReactScanInternals?.options?.value;
if (!opts) return null;
return {
enabled: opts.enabled,
dangerouslyForceRunInProduction: opts.dangerouslyForceRunInProduction,
showToolbar: opts.showToolbar,
};
});
expect(options).toEqual({
enabled: true,
dangerouslyForceRunInProduction: true,
showToolbar: true,
});
});
test('shadow DOM root is created', async ({ page }) => {
await page.waitForTimeout(1000);
expect(await hasShadowRoot(page)).toBe(true);
});
test('toolbar has content in shadow DOM', async ({ page }) => {
await page.waitForTimeout(1000);
const childCount = await page.evaluate(() => {
const root = document.getElementById('react-scan-root');
return root?.shadowRoot?.children.length ?? 0;
});
expect(childCount).toBeGreaterThan(0);
});
test('toolbar persists across interactions', async ({ page }) => {
await page.click('[data-testid="increment"]');
await page.waitForTimeout(500);
const active = await isReactScanActive(page);
expect(active).toBe(true);
const options = await page.evaluate(() => {
return (window as any).__REACT_SCAN__?.ReactScanInternals?.options?.value?.enabled;
});
expect(options).toBe(true);
});
});
================================================
FILE: kitchen-sink/index.html
================================================
React Scan
================================================
FILE: kitchen-sink/package.json
================================================
{
"name": "@react-scan/kitchen-sink",
"type": "module",
"private": true,
"publishConfig": {
"access": "restricted"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scan": "workspace:*",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"postcss": "^8.5.3",
"typescript": "^5.7.3",
"vite": "^6.3.0"
}
}
================================================
FILE: kitchen-sink/postcss.config.mjs
================================================
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: kitchen-sink/src/examples/e2e-fixture/index.tsx
================================================
import { useState, useContext, createContext, memo } from 'react';
import { scan, Store } from 'react-scan';
Store.isInIframe.value = false;
scan({
enabled: true,
dangerouslyForceRunInProduction: true,
});
const ThemeContext = createContext('light');
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
return (
{count}
setCount((c) => c + 1)}>
Increment
);
}
function UnstableProps(): JSX.Element {
const [tick, setTick] = useState(0);
return (
setTick((t) => t + 1)}>
Trigger ({tick})
{}} label="unstable" />
);
}
const MemoChild = memo(function MemoChild({
style,
onClick,
label,
}: {
style: { color: string };
onClick: () => void;
label: string;
}): JSX.Element {
return (
MemoChild: {label}
);
});
function ContextConsumer(): JSX.Element {
const theme = useContext(ThemeContext);
return Theme: {theme}
;
}
function ThemeToggle(): JSX.Element {
const [theme, setTheme] = useState('light');
return (
setTheme((t) => (t === 'light' ? 'dark' : 'light'))}
>
Toggle Theme
);
}
function SlowComponent(): JSX.Element {
const [rendering, setRendering] = useState(false);
const triggerSlowRender = () => {
setRendering(true);
const start = performance.now();
while (performance.now() - start < 100) {
// block for 100ms to simulate slow render
}
setRendering(false);
};
return (
Trigger Slow Render
{rendering ? 'Rendering...' : 'Idle'}
);
}
function RapidUpdater(): JSX.Element {
const [count, setCount] = useState(0);
const triggerRapid = () => {
for (let i = 0; i < 50; i++) {
setTimeout(() => setCount((c) => c + 1), i * 16);
}
};
return (
Rapid Updates
{count}
);
}
export default function E2EFixture(): JSX.Element {
return (
React Scan E2E Fixture
Unstable Props (memo bypass)
);
}
================================================
FILE: kitchen-sink/src/examples/sierpinski/index.tsx
================================================
/**
* Modified version of https://github.com/ryansolid/solid-sierpinski-triangle-demo
**/
// import { Analytics } from '@vercel/analytics/react';
import { useEffect, useMemo, useState } from 'react';
import { scan, Store } from 'react-scan';
import './styles.css';
Store.isInIframe.value = false;
scan({
enabled: true,
dangerouslyForceRunInProduction: true,
});
const TARGET = 50;
const TriangleDemo = () => {
const [elapsed, setElapsed] = useState(0);
const [seconds, setSeconds] = useState(0);
const scale = useMemo(() => {
const e = (elapsed / 1000) % 10;
return 1 + (e > 5 ? 10 - e : e) / 10;
}, [elapsed]);
useEffect(() => {
const t = setInterval(() => setSeconds((s) => (s % 10) + 1), 1000);
let f: number;
const start = Date.now();
const update = () => {
setElapsed(Date.now() - start);
f = requestAnimationFrame(update);
};
f = requestAnimationFrame(update);
return () => {
clearInterval(t);
cancelAnimationFrame(f);
};
}, []);
return (
);
};
interface SlowTriangleProps {
x: number;
y: number;
s: number;
seconds: number;
}
const SlowTriangle = ({ x, y, s, seconds }: SlowTriangleProps) => {
s = s / 2;
const slow = useMemo(() => {
const e = performance.now() + 0.8;
// Artificially long execution time.
while (performance.now() < e) {}
return seconds;
}, [seconds]);
return (
<>
>
);
};
interface TriangleProps {
x: number;
y: number;
s: number;
seconds: number;
}
const Triangle = ({ x, y, s, seconds }: TriangleProps) => {
if (s <= TARGET) {
return (
);
}
return ;
};
interface DotProps {
x: number;
y: number;
s: number;
text: number;
}
const Dot = ({ x, y, s, text }: DotProps) => {
const [hover, setHover] = useState(false);
const onEnter = () => setHover(true);
const onExit = () => setHover(false);
return (
{hover ? '**' + text + '**' : text}
);
};
export default function App(): JSX.Element {
return (
<>
{/* */}
>
);
}
================================================
FILE: kitchen-sink/src/examples/sierpinski/styles.css
================================================
body {
background: #fff;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.7;
margin: 0;
padding: 30px;
}
a {
color: #4183c4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 3px;
font-family: "Bitstream Vera Sans Mono", Consolas, Courier, monospace;
font-size: 12px;
margin: 0 2px;
padding: 0px 5px;
}
h1, h2, h3, h4 {
font-weight: bold;
margin: 0 0 15px;
padding: 0;
}
h1 {
border-bottom: 1px solid #ddd;
font-size: 2.5em;
font-weight: bold;
margin: 0 0 15px;
padding: 0;
}
h2 {
border-bottom: 1px solid #eee;
font-size: 2em;
}
h3 {
font-size: 1.5em;
}
h4 {
font-size: 1.2em;
}
p, ul {
margin: 15px 0;
}
ul {
padding-left: 30px;
}
.container {
position: absolute;
transform-origin: 0 0;
left: 50%;
top: 50%;
width: 10px;
height: 10px;
background: #eee;
}
.dot {
position: absolute;
font: normal 15px sans-serif;
text-align: center;
cursor: pointer;
}
================================================
FILE: kitchen-sink/src/examples/todo-list/index.tsx
================================================
import { useState } from 'react';
import { scan, Store } from 'react-scan';
import './styles.css';
Store.isInIframe.value = false;
scan({
enabled: true,
dangerouslyForceRunInProduction: true,
});
interface TodoItem {
id: number;
message: string;
done: boolean;
}
interface TodoListItemProps {
setList: (action: (list: TodoItem[]) => TodoItem[]) => void;
item: TodoItem;
}
function TodoListItem({ item, setList }: TodoListItemProps): JSX.Element {
return (
{item.message}
{
setList(list =>
list.map(value => {
if (value === item) {
return {
...value,
done: !item.done,
};
}
return value;
}),
);
}}
>
{item.done ? 'Completed' : 'Pending'}
{
setList(list => list.filter(value => value.id !== item.id));
}}
>
Delete
);
}
interface TodoListFormProps {
index: number;
setIndex: (update: number) => void;
setList: (action: (list: TodoItem[]) => TodoItem[]) => void;
}
function TodoListForm({
setList,
index,
setIndex,
}: TodoListFormProps): JSX.Element {
const [message, setMessage] = useState('');
return (
);
}
function TodoList(): JSX.Element {
const [list, setList] = useState([]);
const [index, setIndex] = useState(0);
return (
<>
{list.map(item => (
))}
>
);
}
export default function App(): JSX.Element {
return (
Todo List
);
}
================================================
FILE: kitchen-sink/src/examples/todo-list/styles.css
================================================
* {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
button {
margin: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
outline: none;
color: white;
border-style: none;
cursor: pointer;
transition: background-color 200ms;
}
button:disabled {
background-color: rgba(156, 163, 175, 255);
color: rgba(229, 231, 235, 255);
}
.app {
margin: 5%;
}
.todo-list-form {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.todo-list-form input {
width: 100%;
margin: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
outline: none;
border-width: 1px;
border-color: black;
border-style: solid;
}
.todo-list-form button:not(:disabled) {
background-color: rgba(99, 102, 241, 255);
}
.todo-list-form button:not(:disabled):hover {
background-color: rgba(67, 56, 202, 255);
}
.todo-list-form button:not(:disabled):active {
background-color: rgba(79, 70, 229, 255);
}
.todo-item-toggle.pending {
background-color: rgba(245, 158, 11, 255);
}
.todo-item-toggle.pending:hover {
background-color: rgba(180, 83, 9, 255);
}
.todo-item-toggle.pending:active {
background-color: rgba(217, 119, 6, 255);
}
.todo-item-toggle.complete {
background-color: rgba(16, 185, 129, 255);
}
.todo-item-toggle.complete:hover {
background-color: rgba(4, 120, 87, 255);
}
.todo-item-toggle.complete:active {
background-color: rgba(5, 150, 105, 255);
}
.todo-item-delete:not(:disabled) {
background-color: rgba(239, 68, 68, 255);
}
.todo-item-delete:not(:disabled):hover {
background-color: rgba(185, 28, 28, 255);
}
.todo-item-delete:not(:disabled):active {
background-color: rgba(220, 38, 38, 255);
}
.todo-item {
margin: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 200ms;
}
.todo-item.loading {
background-color: rgba(17, 24, 39, 255);
color: white;
}
.todo-item.complete {
background-color: rgba(209, 250, 229, 255);
}
.todo-item.pending {
background-color: rgba(254, 243, 199, 255);
}
================================================
FILE: kitchen-sink/src/index.css
================================================
body {
margin: 0;
}
================================================
FILE: kitchen-sink/src/index.tsx
================================================
import 'react-scan';
import { FC, lazy } from 'react';
import { createRoot } from 'react-dom/client';
import Home from './main';
import './index.css';
const examples = import.meta.glob }>(
'./examples/**/index.tsx',
{
eager: false,
},
);
const root = document.getElementById('root');
if (root) {
const embedded = new URLSearchParams(window.location.search);
const page = embedded.get('example');
const target = `./examples/${page}/index.tsx`;
if (page && target in examples) {
const App = lazy(examples[target]);
createRoot(root).render( );
} else {
createRoot(root).render( );
}
}
================================================
FILE: kitchen-sink/src/main.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
overflow: hidden;
}
================================================
FILE: kitchen-sink/src/main.tsx
================================================
import { type JSX, useState } from 'react';
import './main.css';
interface Example {
title: string;
url: string;
}
const examples: Example[] = [
{ title: 'Sierpinski Triangle', url: '/?example=sierpinski' },
{ title: 'Todo List', url: '/?example=todo-list' },
];
export default function Home(): JSX.Element {
const [example, setExample] = useState(0);
return (
react-scan
{/* content */}
{/* sidebar */}
{examples.map((item, index) => (
setExample(index)}
>
{item.title}
))}
{/* iframe */}
);
}
================================================
FILE: kitchen-sink/tailwind.config.mjs
================================================
export default {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
};
================================================
FILE: kitchen-sink/tsconfig.json
================================================
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"types": ["vite/client"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"jsx": "react-jsx",
"jsxImportSource": "react",
"esModuleInterop": true,
"target": "ES2017"
}
}
================================================
FILE: kitchen-sink/vite.config.ts
================================================
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
});
================================================
FILE: package.json
================================================
{
"name": "root",
"private": true,
"scripts": {
"build": "node scripts/workspace.mjs build",
"postbuild": "node scripts/version-warning.mjs",
"dev": "node scripts/workspace.mjs dev",
"pack": "node scripts/workspace.mjs pack",
"pack:bump": "pnpm --filter scan pack:bump",
"lint": "pnpm -r lint",
"lint:all": "oxlint .",
"changeset": "changeset add",
"version": "changeset version",
"release": "changeset publish",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@playwright/test": "^1.58.2",
"@types/node": "^22.10.2",
"autoprefixer": "^10.4.20",
"boxen": "^8.0.1",
"chalk": "^5.3.0",
"oxlint": "latest",
"postcss": "^8.5.3",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.17",
"typescript": "latest",
"vite-tsconfig-paths": "^5.1.4"
},
"packageManager": "pnpm@9.1.0",
"dependencies": {
"@vercel/speed-insights": "^1.1.0"
},
"pnpm": {
"overrides": {
"@jridgewell/gen-mapping": "0.3.2",
"@jridgewell/sourcemap-codec": "1.4.15"
}
}
}
================================================
FILE: packages/extension/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.DS_Store
.idea
*.suo
*.ntvs*
*.njsproj
*.sw?
*.sln
# Config files
.webextrc
.webextrc.*
================================================
FILE: packages/extension/CHANGELOG.md
================================================
# @react-scan/extension
## 0.5.3
### Patch Changes
- fix
- Updated dependencies
- react-scan@0.5.3
## 0.5.2
### Patch Changes
- fix
- Updated dependencies
- react-scan@0.5.2
## 0.5.1
### Patch Changes
- fix: infinite mounting
- Updated dependencies
- react-scan@0.5.1
## 0.5.0
### Minor Changes
- cleanup
### Patch Changes
- Updated dependencies
- Updated dependencies [9d38ffe]
- react-scan@0.5.0
================================================
FILE: packages/extension/README.md
================================================
# React Scanner Extension
Browser extension for scanning React applications and identifying performance issues.
### Environment Variables
When developing with Brave, you need to set the `BRAVE_BINARY` environment variable. Create a `.env` file (copy from `.env.example`):
```env
# For macOS
BRAVE_BINARY="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
# For Windows
BRAVE_BINARY="C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
# For Linux
BRAVE_BINARY="/usr/bin/brave"
```
### Development Setup
#### For Chrome
1. Run development server:
```bash
pnpm dev
```
3. This will automatically open Chrome with the extension loaded.
If you need to inspect the extension, open `chrome://extensions` in Chrome
#### For Firefox
#### For Firefox
1. Run development server:
```bash
pnpm dev:firefox
```
2. This will automatically open Firefox with the extension loaded.
If you need to inspect the extension, open `about:debugging#/runtime/this-firefox` in Firefox
#### For Brave
1. Run development server:
```bash
pnpm dev:brave
```
2. This will automatically open Brave with the extension loaded.
If you need to inspect the extension, open `brave://extensions` in Brave
### Building for Production
To build the extension for all browsers:
```bash
pnpm pack:all
```
This will create:
- `chrome-extension-v1.0.8.zip`
- `firefox-extension-v1.0.8.zip`
- `brave-extension-v1.0.8.zip`
in the `build` directory.
================================================
FILE: packages/extension/package.json
================================================
{
"name": "@react-scan/extension",
"version": "0.5.3",
"private": true,
"type": "module",
"scripts": {
"clean": "rimraf dist",
"build": "vite build",
"postbuild": "node ../../scripts/version-warning.mjs",
"dev": "pnpm dev:chrome",
"dev:chrome": "cross-env BROWSER=chrome vite",
"dev:firefox": "cross-env BROWSER=firefox vite",
"dev:brave": "cross-env BROWSER=brave vite",
"mkdir": "mkdir -p build",
"pack:chrome": "pnpm clean && pnpm build && pnpm mkdir && cd dist && zip -r \"../build/chrome-extension-v$npm_package_version.zip\" .",
"pack:firefox": "pnpm clean && BROWSER=firefox pnpm build && pnpm mkdir && cd dist && zip -r \"../build/firefox-extension-v$npm_package_version.zip\" .",
"pack:brave": "pnpm clean && BROWSER=brave pnpm build && pnpm mkdir && cd dist && zip -r \"../build/brave-extension-v$npm_package_version.zip\" .",
"pack:all": "rimraf build && pnpm pack:chrome && pnpm pack:firefox && pnpm pack:brave",
"lint": "oxlint src && pnpm typecheck",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bippy": "0.3.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scan": "workspace:*",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/chrome": "^0.0.281",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/semver": "^7.5.8",
"@types/webextension-polyfill": "^0.12.0",
"@vitejs/plugin-react": "^4.2.1",
"bestzip": "^2.2.1",
"cross-env": "^7.0.3",
"semver": "^7.7.1",
"vite": "^6.3.0",
"vite-plugin-web-extension": "^4.4.3",
"vite-tsconfig-paths": "^5.1.4",
"webextension-polyfill": "^0.12.0"
}
}
================================================
FILE: packages/extension/src/assets/css/no-react.css
================================================
html.freeze > body {
pointer-events: none;
}
html.freeze {
overflow: auto;
overscroll-behavior-x: contain;
}
html.freeze svg {
pointer-events: none;
}
html.freeze #react-scan-toast {
pointer-events: auto;
}
#react-scan-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.01);
backdrop-filter: blur(94px) saturate(180%);
animation-duration: 300ms;
opacity: 0;
pointer-events: none;
transition: opacity 500ms;
z-index: 2147483650;
}
#react-scan-toast {
position: fixed;
top: 50%;
left: 50%;
display: flex;
flex-direction: column;
padding: 12px 40px 12px 16px;
min-width: 320px;
max-width: 480px;
color: #fff;
font-size: 12px;
font-family: Menlo, Consolas, Monaco, 'Liberation Mono', 'Lucida Console', monospace;
line-height: 1.5;
background: rgba(0, 0, 0, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translate(-50%, -50%);
transition: opacity 300ms ease-in-out;
z-index: 2147483651;
}
#react-scan-toast-title {
margin: 0 0 8px 0;
}
#react-scan-toast-message {
display: flex;
align-items: flex-start;
}
#react-scan-toast-message span {
white-space: pre-line;
}
#react-scan-toast .icon {
font-size: .75rem;
margin-right: 8px;
}
@media (max-width: 320px) {
#react-scan-toast {
flex-direction: column;
min-width: auto;
width: calc(100% - 36px);
}
}
@media (max-width: 720px) {
#react-scan-toast {
br {
display: none;
}
}
}
#react-scan-toast-close-button {
position: absolute;
top: 10px;
right: 8px;
display: flex;
align-items: center;
width: 23px;
height: 23px;
padding: 4px;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
background: rgba(255, 255, 255, 0.01);
transition: background-color 300ms ease;
}
#react-scan-toast-close-button:hover {
background-color: rgba(255, 255, 255, 0.15);
}
================================================
FILE: packages/extension/src/background/icon.ts
================================================
import browser from 'webextension-polyfill';
export enum IconState {
DISABLED = 'disabled',
ENABLED = 'enabled',
}
const browserAction = browser.action || browser.browserAction;
const cachedIcons = {
[IconState.ENABLED]: {
path: {
16: browser.runtime.getURL('icons/enabled/16.png'),
32: browser.runtime.getURL('icons/enabled/32.png'),
48: browser.runtime.getURL('icons/enabled/48.png'),
128: browser.runtime.getURL('icons/enabled/128.png'),
},
},
[IconState.DISABLED]: {
path: {
16: browser.runtime.getURL('icons/disabled/16.png'),
32: browser.runtime.getURL('icons/disabled/32.png'),
48: browser.runtime.getURL('icons/disabled/48.png'),
128: browser.runtime.getURL('icons/disabled/128.png'),
},
},
};
export const updateIconForTab = async (
tab: browser.Tabs.Tab,
state: IconState,
badgeText = 'on',
): Promise => {
try {
switch (state) {
case IconState.ENABLED:
await browserAction.setIcon({
tabId: tab.id,
path: cachedIcons[IconState.ENABLED].path,
});
if (badgeText) {
await browserAction.setBadgeText({ text: badgeText, tabId: tab.id });
await browserAction.setBadgeBackgroundColor({
color: '#A295EE',
tabId: tab.id,
});
}
break;
default:
await browserAction.setIcon({
tabId: tab.id,
path: cachedIcons[IconState.DISABLED].path,
});
await browserAction.setBadgeText({ text: '', tabId: tab.id });
break;
}
} catch {}
};
================================================
FILE: packages/extension/src/background/index.ts
================================================
import browser from 'webextension-polyfill';
import { isInternalUrl } from '~utils/helpers';
import { IconState, updateIconForTab } from './icon';
import { BroadcastMessage } from '~types/messages';
const browserAction = browser.action || browser.browserAction;
const injectScripts = async (tabId: number) => {
try {
await browser.scripting.executeScript({
target: { tabId },
files: ['src/content/index.js', 'src/inject/index.js'],
});
await browser.tabs.sendMessage(tabId, {
type: 'react-scan:page-reload',
});
} catch (e) {
// oxlint-disable-next-line no-console
console.error('Script injection error:', e);
}
};
const isScriptsLoaded = async (tabId: number): Promise => {
try {
await browser.tabs.sendMessage(tabId, { type: 'react-scan:ping' });
return true;
} catch {
return false;
}
};
const init = async (tab: browser.Tabs.Tab) => {
if (!tab.id || !tab.url || isInternalUrl(tab.url)) {
if (tab.id) {
await updateIconForTab(tab, IconState.DISABLED);
}
return;
}
const isLoaded = await isScriptsLoaded(tab.id);
if (!isLoaded) {
await injectScripts(tab.id);
}
if (!isLoaded) {
await updateIconForTab(tab, IconState.DISABLED);
}
};
browser.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
void init(tab);
}
});
browser.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await browser.tabs.get(tabId);
void init(tab);
});
browser.windows.onFocusChanged.addListener(async (windowId) => {
if (windowId !== browser.windows.WINDOW_ID_NONE) {
const [tab] = await browser.tabs.query({ active: true, windowId });
if (tab) {
void init(tab);
}
}
});
browser.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (tab) {
void init(tab);
}
});
browserAction.onClicked.addListener(async (tab) => {
if (!tab.id || !tab.url || isInternalUrl(tab.url)) {
if (tab.id) {
await updateIconForTab(tab, IconState.DISABLED);
}
return;
}
try {
await browser.tabs.sendMessage(tab.id, {
type: 'react-scan:toggle-state',
});
await updateIconForTab(tab, IconState.DISABLED);
} catch {
if (tab.id) {
await updateIconForTab(tab, IconState.DISABLED);
}
}
});
browser.runtime.onMessage.addListener(
(message: unknown, sender: browser.Runtime.MessageSender) => {
const msg = message as BroadcastMessage;
if (!sender.tab?.id) return;
if (msg.type === 'react-scan:is-enabled') {
void updateIconForTab(
sender.tab,
msg.data?.state ? IconState.ENABLED : IconState.DISABLED,
);
}
},
);
================================================
FILE: packages/extension/src/content/index.ts
================================================
import browser from 'webextension-polyfill';
import {
type BroadcastMessage,
BroadcastSchema,
type IEvents,
} from '~types/messages';
import { busDispatch, busSubscribe } from '~utils/helpers';
chrome.runtime.onMessage.addListener(
async (message: unknown, _sender, sendResponse) => {
const result = BroadcastSchema.safeParse(message);
if (!result.success) {
return false;
}
const data = result.data;
if (data.type === 'react-scan:ping') {
sendResponse({ pong: true });
return false;
}
if (data.type === 'react-scan:page-reload') {
window.location.reload();
return false;
}
if (data.type === 'react-scan:toggle-state') {
busDispatch(
'react-scan:toggle-state',
{
topic: 'react-scan:toggle-state',
message: undefined,
},
);
return false;
}
return false;
},
);
const sendMessageToBackground = ({ type, data }: BroadcastMessage) => {
try {
return browser.runtime.sendMessage({ type, data });
} catch {
return Promise.resolve();
}
};
busSubscribe(
'react-scan:send-to-background',
(event) => {
sendMessageToBackground(event.message);
},
);
================================================
FILE: packages/extension/src/inject/index.ts
================================================
import * as reactScan from 'react-scan';
import { gt } from 'semver';
import type { IEvents } from '~types/messages';
import { EXTENSION_STORAGE_KEY, STORAGE_KEY } from '~utils/constants';
import {
busDispatch,
busSubscribe,
canLoadReactScan,
hasReactFiber,
readLocalStorage,
saveLocalStorage,
sleep,
storageGetItem,
storageSetItem,
} from '~utils/helpers';
import { createNotificationUI, toggleNotification } from './notification';
const reactScanExtensionVersion = 'version' in reactScan.ReactScanInternals ? (reactScan.ReactScanInternals as any).version : undefined;
const isTargetPageAlreadyUsedReactScan = () => {
const currentReactScanVersion = window.__REACT_SCAN_VERSION__;
if (
window.__REACT_SCAN__?.ReactScanInternals?.Store?.monitor?.value &&
!currentReactScanVersion
) {
return true;
}
if (!reactScanExtensionVersion || !currentReactScanVersion) {
return false;
}
return gt(currentReactScanVersion, reactScanExtensionVersion);
};
const getInitialOptions = async (): Promise => {
const storedOptions = readLocalStorage(STORAGE_KEY);
let isEnabled = false;
try {
const storedEnabled = await storageGetItem(
EXTENSION_STORAGE_KEY,
'isEnabled',
);
isEnabled = storedEnabled ?? false;
} catch {}
return {
...storedOptions,
enabled: isEnabled,
showToolbar: isEnabled,
dangerouslyForceRunInProduction: true,
};
};
const initializeReactScan = async () => {
const options = await getInitialOptions();
window.__REACT_SCAN_EXTENSION__ = true;
if (options.enabled) {
window.hideIntro = true;
reactScan.scan(options);
window.reactScan = undefined;
}
};
let timer: number | undefined;
const updateReactScanState = async (isEnabled: boolean | null) => {
clearTimeout(timer);
const toggledState = isEnabled === null ? true : !isEnabled;
try {
await storageSetItem(EXTENSION_STORAGE_KEY, 'isEnabled', toggledState);
} catch {}
const storedOptions = readLocalStorage(STORAGE_KEY) ?? {};
const updatedOptions = {
...storedOptions,
enabled: toggledState,
showToolbar: toggledState,
dangerouslyForceRunInProduction: true,
};
saveLocalStorage(STORAGE_KEY, updatedOptions);
window.location.reload();
};
void initializeReactScan();
window.addEventListener('DOMContentLoaded', async () => {
if (!canLoadReactScan) {
return;
}
let isReactAvailable = false;
await sleep(1000);
isReactAvailable = await hasReactFiber();
if (!isReactAvailable) {
createNotificationUI({
title: 'React Not Detected',
content:
"React is not detected on this page.\nPlease ensure you're visiting a React application.",
});
busDispatch(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: false,
},
},
},
);
busSubscribe(
'react-scan:toggle-state',
async () => {
toggleNotification();
},
);
return;
}
if (isTargetPageAlreadyUsedReactScan()) {
createNotificationUI({
title: 'Already Initialized',
content: 'React Scan is already initialized on this page.',
});
busDispatch(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: false,
},
},
},
);
busSubscribe(
'react-scan:toggle-state',
async () => {
toggleNotification();
},
);
return;
}
const storedOptions = readLocalStorage(STORAGE_KEY);
if (storedOptions !== null) {
busDispatch(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: storedOptions.showToolbar,
},
},
},
);
}
if (!isTargetPageAlreadyUsedReactScan()) {
window.reactScan = reactScan.setOptions;
}
busSubscribe(
'react-scan:toggle-state',
async () => {
if (!isReactAvailable || isTargetPageAlreadyUsedReactScan()) {
toggleNotification();
return;
}
try {
const isEnabled = await storageGetItem(
EXTENSION_STORAGE_KEY,
'isEnabled',
);
await updateReactScanState(!!isEnabled);
} catch {
await updateReactScanState(null);
}
},
);
});
================================================
FILE: packages/extension/src/inject/notification.ts
================================================
import noReactStyles from '~assets/css/no-react.css?inline';
import type { IEvents } from '~types/messages';
import { busDispatch } from '~utils/helpers';
let backdrop: HTMLDivElement | null = null;
let isAnimating = false;
const defaultTitle = 'React Not Detected';
const defaultContent =
"React is not detected on this page. \nPlease ensure you're visiting a React application!";
export const createNotificationUI = ({
title = defaultTitle,
content = defaultContent,
}) => {
busDispatch(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: false,
},
},
},
);
if (backdrop) {
return;
}
backdrop = document.createElement('div');
backdrop.id = 'react-scan-backdrop';
backdrop.style.opacity = '0';
backdrop.style.pointerEvents = 'none';
const toast = document.createElement('div');
toast.id = 'react-scan-toast';
toast.onclick = (e) => {
e.stopPropagation();
};
// Create title element
const titleElement = document.createElement('div');
titleElement.id = 'react-scan-toast-title';
const icon = document.createElement('span');
icon.className = 'icon';
icon.textContent = '⚛️';
titleElement.appendChild(icon);
const titleText = document.createElement('span');
titleText.textContent = title;
titleElement.appendChild(titleText);
toast.appendChild(titleElement);
// Create message element
const messageElement = document.createElement('div');
messageElement.id = 'react-scan-toast-message';
const text = document.createElement('span');
text.textContent = content;
text.style.whiteSpace = 'pre-line'; // Preserve line breaks
messageElement.appendChild(text);
toast.appendChild(messageElement);
const button = document.createElement('button');
button.id = 'react-scan-toast-close-button';
button.type = 'button';
button.onclick = toggleNotification;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '15');
svg.setAttribute('height', '15');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
const line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line1.setAttribute('x1', '18');
line1.setAttribute('y1', '6');
line1.setAttribute('x2', '6');
line1.setAttribute('y2', '18');
const line2 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line2.setAttribute('x1', '6');
line2.setAttribute('y1', '6');
line2.setAttribute('x2', '18');
line2.setAttribute('y2', '18');
svg.appendChild(line1);
svg.appendChild(line2);
button.appendChild(svg);
toast.appendChild(button);
backdrop.appendChild(toast);
backdrop.onclick = toggleNotification;
const style = document.createElement('style');
style.id = 'react-scan-no-react-styles';
style.appendChild(document.createTextNode(noReactStyles));
const fragment = document.createDocumentFragment();
fragment.appendChild(style);
fragment.appendChild(backdrop);
document.documentElement.appendChild(fragment);
};
export const toggleNotification = () => {
if (!backdrop || isAnimating) return;
isAnimating = true;
const handleTransitionEnd = () => {
isAnimating = false;
backdrop?.removeEventListener('transitionend', handleTransitionEnd);
};
backdrop.addEventListener('transitionend', handleTransitionEnd);
const isVisible = backdrop.style.opacity === '1';
backdrop.style.opacity = isVisible ? '0' : '1';
backdrop.style.pointerEvents = isVisible ? 'none' : 'auto';
document.documentElement.classList.toggle('freeze', !isVisible);
};
================================================
FILE: packages/extension/src/inject/react-scan.ts
================================================
// Bippy has a side-effect that installs the hook.
import 'bippy';
================================================
FILE: packages/extension/src/manifest.chrome.json
================================================
{
"manifest_version": 3,
"name": "React Scan",
"version": "0.0.0",
"description": "Scan React apps for performance problems",
"icons": {
"16": "icons/disabled/16.png",
"32": "icons/disabled/32.png",
"48": "icons/disabled/48.png",
"128": "icons/disabled/128.png"
},
"action": {
"default_icon": {
"16": "icons/disabled/16.png",
"32": "icons/disabled/32.png",
"48": "icons/disabled/48.png",
"128": "icons/disabled/128.png"
}
},
"background": {
"service_worker": "src/background/index.ts"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"permissions": ["activeTab", "tabs", "scripting"],
"host_permissions": [""],
"content_scripts": [
{
"matches": [""],
"js": ["src/inject/react-scan.ts"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": [""],
"js": ["src/inject/index.ts"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": [""],
"js": ["src/content/index.ts"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": ["icons/*"],
"matches": [""]
}
]
}
================================================
FILE: packages/extension/src/manifest.firefox.json
================================================
{
"manifest_version": 2,
"name": "React Scan",
"version": "0.0.0",
"description": "Scan React apps for performance problems",
"icons": {
"16": "icons/disabled/16.png",
"32": "icons/disabled/32.png",
"48": "icons/disabled/48.png",
"128": "icons/disabled/128.png"
},
"browser_specific_settings": {
"gecko": {
"id": "react-scan@million.dev",
"strict_min_version": "57.0"
}
},
"browser_action": {
"default_icon": {
"16": "icons/disabled/16.png",
"32": "icons/disabled/32.png",
"48": "icons/disabled/48.png",
"128": "icons/disabled/128.png"
}
},
"background": {
"scripts": ["src/background/index.ts"]
},
"permissions": ["activeTab", "tabs", "scripting", ""],
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [
{
"matches": [""],
"js": ["src/inject/react-scan.ts"],
"run_at": "document_start"
},
{
"matches": [""],
"js": ["src/inject/index.ts"],
"run_at": "document_start"
},
{
"matches": [""],
"js": ["src/content/index.ts"],
"run_at": "document_start"
}
],
"web_accessible_resources": ["icons/*"]
}
================================================
FILE: packages/extension/src/types/global.d.ts
================================================
import type * as reactScan from 'react-scan';
declare global {
interface Window {
__REACT_SCAN__?: {
ReactScanInternals: {
version: string;
Store: {
monitor: {
value: boolean;
};
};
};
};
__REACT_SCAN_EXTENSION__?: boolean;
__REACT_SCAN_VERSION__?: string;
__REACT_DEVTOOLS_GLOBAL_HOOK__?: {
checkDCE: (fn: unknown) => void;
supportsFiber: boolean;
supportsFlight: boolean;
renderers: Map;
hasUnsupportedRendererAttached: boolean;
onCommitFiberRoot: (
rendererID: number,
root: FiberRoot,
priority: void | number,
) => void;
onCommitFiberUnmount: (rendererID: number, fiber: Fiber) => void;
onPostCommitFiberRoot: (rendererID: number, root: FiberRoot) => void;
inject: (renderer: ReactRenderer) => number;
_instrumentationSource?: string;
_instrumentationIsActive?: boolean;
};
hideIntro: boolean;
reactScan: typeof reactScan.setOptions | undefined;
}
}
================================================
FILE: packages/extension/src/types/messages.ts
================================================
import { z } from 'zod';
export const BroadcastSchema = z.object({
type: z.enum([
'react-scan:ping',
'react-scan:is-enabled',
'react-scan:toggle-state',
'react-scan:page-reload',
]),
data: z.any().optional(),
});
export type BroadcastMessage = z.infer;
export interface IEvents {
'react-scan:toggle-state': {
topic: 'react-scan:toggle-state';
message: undefined;
};
'react-scan:send-to-background': {
topic: 'react-scan:send-to-background';
message: BroadcastMessage;
};
}
================================================
FILE: packages/extension/src/utils/constants.ts
================================================
export const STORAGE_KEY = 'react-scan-options';
export const EXTENSION_STORAGE_KEY = 'react-scan-extension';
================================================
FILE: packages/extension/src/utils/helpers.ts
================================================
export const isIframe = window !== window.top;
export const isPopup = window.opener !== null;
export const canLoadReactScan = !isIframe && !isPopup;
export const IS_CLIENT = typeof window !== 'undefined';
export const isInternalUrl = (url: string): boolean => {
if (!url) return false;
const allowedProtocols = ['http:', 'https:', 'file:'];
return !allowedProtocols.includes(new URL(url).protocol);
};
interface ReactRootContainer {
_reactRootContainer?: {
_internalRoot?: {
current?: {
child: unknown;
};
};
};
__reactContainer$?: unknown;
}
const ReactDetection = {
limits: {
MAX_DEPTH: 10,
MAX_ELEMENTS: 30,
ELEMENTS_PER_LEVEL: 5
},
nonVisualTags: new Set([
// Document level
'HTML', 'HEAD', 'META', 'TITLE', 'BASE',
// Scripts and styles
'SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT',
// Media and embeds
'SOURCE', 'TRACK', 'EMBED', 'OBJECT', 'PARAM',
// Special elements
'TEMPLATE', 'PORTAL', 'SLOT',
// Others
'AREA', 'XML', 'DOCTYPE', 'COMMENT'
]),
reactMarkers: {
root: '_reactRootContainer',
fiber: '__reactFiber',
instance: '__reactInternalInstance$',
container: '__reactContainer$'
}
} as const;
const childrenCache = new WeakMap();
export const hasReactFiber = (): boolean => {
const rootElement = document.body;
let elementsChecked = 0;
const getChildren = (element: Element): Element[] => {
let children = childrenCache.get(element);
if (!children) {
const childNodes = element.children;
children = [];
for (let i = 0; i < childNodes.length; i++) {
const child = childNodes[i];
if (!ReactDetection.nonVisualTags.has(child.tagName)) {
children.push(child);
}
}
childrenCache.set(element, children);
}
return children;
};
const checkElement = (element: Element, depth: number): boolean => {
if (elementsChecked >= ReactDetection.limits.MAX_ELEMENTS) return false;
elementsChecked++;
const props = Object.getOwnPropertyNames(element);
if (ReactDetection.reactMarkers.root in element) {
const elementWithRoot = element as unknown as ReactRootContainer;
const rootContainer = elementWithRoot._reactRootContainer;
const hasLegacyRoot = rootContainer?._internalRoot?.current?.child != null;
const hasContainerRoot = Object.keys(elementWithRoot).some(key =>
key.startsWith(ReactDetection.reactMarkers.container)
);
return hasLegacyRoot || hasContainerRoot;
}
for (const key of props) {
if (
key.startsWith(ReactDetection.reactMarkers.fiber) ||
key.startsWith(ReactDetection.reactMarkers.instance)
) {
return true;
}
}
if (depth < ReactDetection.limits.MAX_DEPTH) {
const children = getChildren(element);
const maxCheck = Math.min(children.length, ReactDetection.limits.ELEMENTS_PER_LEVEL);
for (let i = 0; i < maxCheck; i++) {
if (checkElement(children[i], depth + 1)) {
return true;
}
}
}
return false;
};
return checkElement(rootElement, 0);
};
export const readLocalStorage = (storageKey: string): T | null => {
if (!IS_CLIENT) return null;
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
};
export const saveLocalStorage = (storageKey: string, state: T): void => {
if (!IS_CLIENT) return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(state));
} catch {}
};
export const removeLocalStorage = (storageKey: string): void => {
if (!IS_CLIENT) return;
try {
window.localStorage.removeItem(storageKey);
} catch {}
};
export const debounce = Promise>(
fn: T,
wait: number,
options: { leading?: boolean; trailing?: boolean } = {},
) => {
let timeoutId: number | undefined;
let lastArg: boolean | null | undefined;
let isLeadingInvoked = false;
const debounced = (enabled: boolean | null) => {
lastArg = enabled;
if (options.leading && !isLeadingInvoked) {
isLeadingInvoked = true;
fn(enabled);
return;
}
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
if (options.trailing !== false) {
timeoutId = setTimeout(() => {
isLeadingInvoked = false;
timeoutId = undefined;
if (lastArg !== undefined) {
fn(lastArg);
}
}, wait);
}
};
debounced.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
isLeadingInvoked = false;
lastArg = undefined;
}
};
return debounced;
};
type EventCallback = (data: T) => void;
const eventBus = new Map>();
export const busSubscribe = (
event: string,
callback: EventCallback,
): (() => void) => {
if (!eventBus.has(event)) {
eventBus.set(event, new Set());
}
eventBus.get(event)!.add(callback as EventCallback);
return () => {
const callbacks = eventBus.get(event);
if (callbacks) {
callbacks.delete(callback as EventCallback);
if (callbacks.size === 0) {
eventBus.delete(event);
}
}
};
};
export const busDispatch = (event: string, data: T): void => {
const callbacks = eventBus.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
};
export const sleep = (ms: number): Promise => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const storageGetItem = async (
storageKey: string,
key: string,
): Promise => {
try {
const result = await chrome.storage.local.get(storageKey);
const data = result[storageKey];
return data?.[key] ?? null;
} catch {
return null;
}
};
export const storageSetItem = async (
storageKey: string,
key: string,
value: T,
): Promise => {
try {
const result = await chrome.storage.local.get(storageKey);
const data = result[storageKey] || {};
data[key] = value;
await chrome.storage.local.set({ [storageKey]: data });
} catch {
}
};
================================================
FILE: packages/extension/src/vite-env.d.ts
================================================
///
================================================
FILE: packages/extension/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"react-scan": [
"../scan/dist"
],
"~utils/*": [
"./src/utils/*"
],
"~types/*": [
"./src/types/*"
],
"~assets/*": [
"./src/assets/*"
]
},
"types": [
"chrome"
]
},
"include": [
"src"
],
"references": [{ "path": "./tsconfig.node.json" }]
}
================================================
FILE: packages/extension/tsconfig.node.json
================================================
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: packages/extension/vite.config.ts
================================================
import react from '@vitejs/plugin-react';
import { type UserConfig, defineConfig, loadEnv } from 'vite';
import webExtension, { readJsonFile } from 'vite-plugin-web-extension';
import tsconfigPaths from 'vite-tsconfig-paths';
// Browser types
const BROWSER_TYPES = {
CHROME: 'chrome',
FIREFOX: 'firefox',
BRAVE: 'brave',
} as const;
type BrowserType = (typeof BROWSER_TYPES)[keyof typeof BROWSER_TYPES];
export default defineConfig(({ mode }): UserConfig => {
const env = loadEnv(mode, process.cwd(), '');
const browser = (env.BROWSER || BROWSER_TYPES.CHROME) as BrowserType;
const isBrave = browser === BROWSER_TYPES.BRAVE;
// Validate Brave binary
if (env.NODE_ENV === 'development' && isBrave && !env.BRAVE_BINARY) {
// oxlint-disable-next-line no-console
console.error(`
⚛️ React Scan
==============
🚫 Error: BRAVE_BINARY environment variable is missing
This is required for Brave browser development.
Please check .env.example and set up your .env file with the correct path:
📍 For macOS:
BRAVE_BINARY="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
📍 For Windows:
BRAVE_BINARY="C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
📍 For Linux:
BRAVE_BINARY="/usr/bin/brave"
===============
`);
process.exit(0);
}
// Get browser binary based on type
const getBrowserBinary = () => {
switch (browser) {
case BROWSER_TYPES.FIREFOX:
return env.FIREFOX_BINARY;
case BROWSER_TYPES.BRAVE:
return env.BRAVE_BINARY || env.CHROME_BINARY;
case BROWSER_TYPES.CHROME:
return env.CHROME_BINARY;
default:
return env.CHROME_BINARY;
}
};
// Generate manifest with package info
const generateManifest = () => {
const manifestPath =
browser === BROWSER_TYPES.FIREFOX
? 'src/manifest.firefox.json'
: 'src/manifest.chrome.json';
const manifest = readJsonFile(manifestPath);
const pkg = readJsonFile('package.json');
return {
name: pkg.name,
description: pkg.description,
...manifest,
version: pkg.version,
};
};
// Vite configuration
return {
build: {
minify: 'esbuild' as const,
},
esbuild: {
keepNames: true,
minifyIdentifiers: false,
},
plugins: [
react(),
tsconfigPaths(),
webExtension({
manifest: generateManifest,
// Use Chrome config for Brave
webExtConfig: {
target: isBrave
? 'chromium'
: browser === 'firefox'
? 'firefox-desktop'
: 'chromium',
chromiumBinary: getBrowserBinary(),
firefoxBinary: env.FIREFOX_BINARY,
startUrl: ['https://github.com/aidenybai/react-scan'],
},
}),
],
optimizeDeps: {
exclude: ['react-scan'],
},
};
});
================================================
FILE: packages/scan/.gitignore
================================================
src/web/assets/css/styles.css
================================================
FILE: packages/scan/CHANGELOG.md
================================================
# react-scan
## 0.5.3
### Patch Changes
- fix
## 0.5.2
### Patch Changes
- fix
## 0.5.1
### Patch Changes
- fix: infinite mounting
## 0.5.0
### Minor Changes
- cleanup
- 9d38ffe: Remove monitoring module, replace Playwright CLI with interactive init command, clean up dead code
- Removed the entire monitoring system (`packages/scan/src/core/monitor/`) and all related exports, types, and build entries
- Replaced the Playwright-based proxy CLI (`npx react-scan `) with an interactive `npx react-scan init` command that auto-detects your framework and sets up React Scan
- Removed unused code: old outline system, LRU cache, lazy refs, commented-out code blocks, and unused exports
- Consolidated duplicate utilities (safeGetValue, RenderPhase types)
- Simplified README to focus on the new init command
- Added CLI quick-start command to the website homepage
================================================
FILE: packages/scan/README.md
================================================
# React Scan
React Scan automatically detects performance issues in your React app.
Previously, tools like:
- [` `](https://react.dev/reference/react/Profiler) required lots of manual changes
- [Why Did You Render?](https://github.com/welldone-software/why-did-you-render) lacked simple visual cues
- [React Devtools](https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html) didn't have a simple, portable, and programmatic API
React Scan attempts to solve these problems:
- It requires no code changes – just drop it in
- It highlights exactly the components you need to optimize
- Use it via script tag, npm, CLI, you name it!
Trusted by engineering teams at:
Airbnb
### [**Try it out! →**](https://react-scan.million.dev)

> [!IMPORTANT]
> Want to monitor issues in production? Check out [React Scan Monitoring](https://react-scan.com/monitoring)!
## Install
### Package managers
```bash
npm i react-scan
```
```bash
pnpm add react-scan
```
```bash
yarn add react-scan
```
### CDN
```html
```
## Usage
- [NextJS App Router](https://github.com/aidenybai/react-scan/blob/main/docs/installation/next-js-app-router.md)
- [NextJS Page Router](https://github.com/aidenybai/react-scan/blob/main/docs/installation/next-js-page-router.md)
- [Create React App](https://github.com/aidenybai/react-scan/blob/main/docs/installation/create-react-app.md)
- [Vite](https://github.com/aidenybai/react-scan/blob/main/docs/installation/vite.md)
- [Parcel](https://github.com/aidenybai/react-scan/blob/main/docs/installation/parcel.md)
- [Remix](https://github.com/aidenybai/react-scan/blob/main/docs/installation/remix.md)
- [React Router](https://github.com/aidenybai/react-scan/blob/main/docs/installation/react-router.md)
- [Astro](https://github.com/aidenybai/react-scan/blob/main/docs/installation/astro.md)
- [TanStack Start](https://github.com/aidenybai/react-scan/blob/main/docs/installation/tanstack-start.md)
### CLI
If you don't have a local version of the site or you want to test a React app remotely, you can use the CLI. This will spin up an isolated browser instance which you can interact or use React Scan with.
```bash
npx react-scan@latest http://localhost:3000
# you can technically scan ANY website on the web:
# npx react-scan@latest https://react.dev
```
You can add it to your existing dev process as well. Here's an example for Next.js:
```json
{
"scripts": {
"dev": "next dev",
"scan": "next dev & npx react-scan@latest localhost:3000"
}
}
```
### Browser Extension
If you want to install the extension, follow the guide [here](https://github.com/aidenybai/react-scan/blob/main/BROWSER_EXTENSION_GUIDE.md).
### React Native
See [discussion](https://github.com/aidenybai/react-scan/pull/23)
## API Reference
Options
```tsx
export interface Options {
/**
* Enable/disable scanning
*
* Please use the recommended way:
* enabled: process.env.NODE_ENV === 'development',
*
* @default true
*/
enabled?: boolean;
/**
* Force React Scan to run in production (not recommended)
*
* @default false
*/
dangerouslyForceRunInProduction?: boolean;
/**
* Log renders to the console
*
* WARNING: This can add significant overhead when the app re-renders frequently
*
* @default false
*/
log?: boolean;
/**
* Show toolbar bar
*
* If you set this to true, and set {@link enabled} to false, the toolbar will still show, but scanning will be disabled.
*
* @default true
*/
showToolbar?: boolean;
/**
* Animation speed
*
* @default "fast"
*/
animationSpeed?: "slow" | "fast" | "off";
/**
* Track unnecessary renders, and mark their outlines gray when detected
*
* An unnecessary render is defined as the component re-rendering with no change to the component's
* corresponding dom subtree
*
* @default false
* @warning tracking unnecessary renders can add meaningful overhead to react-scan
*/
trackUnnecessaryRenders?: boolean;
onCommitStart?: () => void;
onRender?: (fiber: Fiber, renders: Array) => void;
onCommitFinish?: () => void;
onPaintStart?: (outlines: Array) => void;
onPaintFinish?: (outlines: Array) => void;
}
```
- `scan(options: Options)`: Imperative API to start scanning
- `useScan(options: Options)`: Hook API to start scanning
- `getReport()`: Get a report of all the renders
- `setOptions(options: Options): void`: Set options at runtime
- `getOptions()`: Get the current options
- `onRender(Component, onRender: (fiber: Fiber, render: Render) => void)`: Hook into a specific component's renders
## Why React Scan?
React can be tricky to optimize.
The issue is that component props are compared by reference, not value. This is intentional – this way rendering can be cheap to run.
However, this makes it easy to accidentally cause unnecessary renders, making the app slow. Even in production apps, with hundreds of engineers, can't fully optimize their apps (see [GitHub](https://github.com/aidenybai/react-scan/blob/main/.github/assets/github.mp4), [Twitter](https://github.com/aidenybai/react-scan/blob/main/.github/assets/twitter.mp4), and [Instagram](https://github.com/aidenybai/react-scan/blob/main/.github/assets/instagram.mp4)).
This often comes down to props that update in reference, like callbacks or object values. For example, the `onClick` function and `style` object are re-created on every render, causing `ExpensiveComponent` to slow down the app:
```jsx
alert("hi")} style={{ color: "purple" }} />
```
React Scan helps you identify these issues by automatically detecting and highlighting renders that cause performance issues. Now, instead of guessing, you can see exactly which components you need to fix.
> Want monitor issues in production? Check out [React Scan Monitoring](https://react-scan.com/monitoring)!
### FAQ
**Q: Why this instead of React Devtools?**
React Devtools aims to be a general purpose tool for React. However, I deal with React performance issues every day, and React Devtools doesn't fix my problems well. There's a lot of noise (no obvious distinction between unnecessary and necessary renders), and there's no programmatic API. If it sounds like you have the same problems, then React Scan may be a better choice.
Also, some personal complaints about React Devtools' highlight feature:
- React Devtools "batches" paints, so if a component renders too fast, it will lag behind and only show 1 every second or so
- When you scroll/resize the boxes don't update position
- No count of how many renders there are
- I don't know what the bad/slow renders are without inspecting
- The menu is hidden away so it's annoying to turn on/off, user experience should be specifically tuned for debugging performance, instead of hidden behind a profiler/component tree
- No programmatic API
- It's stuck in a chrome extension, I want to run it anywhere on the web
- It looks subjectively ugly (lines look fuzzy, feels sluggish)
- I'm more ambitious with react-scan
## Resources & Contributing Back
Want to try it out? Check the [our demo](https://react-scan.million.dev).
Looking to contribute back? Check the [Contributing Guide](https://github.com/aidenybai/react-scan/blob/main/CONTRIBUTING.md) out.
Want to talk to the community? Hop in our [Discord](https://discord.gg/X9yFbcV2rF) and share your ideas and what you've build with React Scan.
Find a bug? Head over to our [issue tracker](https://github.com/aidenybai/react-scan/issues) and we'll do our best to help. We love pull requests, too!
We expect all contributors to abide by the terms of our [Code of Conduct](https://github.com/aidenybai/react-scan/blob/main/.github/CODE_OF_CONDUCT.md).
[**→ Start contributing on GitHub**](https://github.com/aidenybai/react-scan/blob/main/CONTRIBUTING.md)
## Acknowledgments
React Scan takes inspiration from the following projects:
- [React Devtools](https://react.dev/learn/react-developer-tools) for the initial idea of [highlighting renders](https://medium.com/dev-proto/highlight-react-components-updates-1b2832f2ce48). We chose to diverge from this to provide a [better developer experience](https://x.com/aidenybai/status/1857122670929969551)
- [Million Lint](https://million.dev) for scanning and linting approaches
- [Why Did You Render?](https://github.com/welldone-software/why-did-you-render) for the concept of hijacking internals to detect unnecessary renders caused by "unstable" props
## License
React Scan is [MIT-licensed](LICENSE) open-source software by Aiden Bai, [Million Software, Inc.](https://million.dev), and [contributors](https://github.com/aidenybai/react-scan/graphs/contributors).
================================================
FILE: packages/scan/auto.d.ts
================================================
export * from './dist/auto';
================================================
FILE: packages/scan/bin/cli.js
================================================
#! /usr/bin/env node
require('../dist/cli');
================================================
FILE: packages/scan/global.d.ts
================================================
declare module '*.css' {
const content: string;
export default content;
}
declare module '*.astro' {
const Component: unknown;
export default Component;
}
================================================
FILE: packages/scan/package.json
================================================
{
"name": "react-scan",
"version": "0.5.3",
"description": "Scan your React app for renders",
"keywords": [
"react",
"react-scan",
"react scan",
"render",
"performance"
],
"homepage": "https://react-scan.million.dev",
"bugs": {
"url": "https://github.com/aidenybai/react-scan/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/aidenybai/react-scan.git"
},
"license": "MIT",
"author": {
"name": "Aiden Bai",
"email": "aiden@million.dev",
"url": "https://million.dev"
},
"scripts": {
"build": "npm run build:css && NODE_ENV=production tsup",
"postbuild": "node ../../scripts/version-warning.mjs",
"build:copy": "npm run build:css && NODE_ENV=production tsup && cat dist/auto.global.js | pbcopy",
"dev:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css --watch",
"dev:tsup": "NODE_ENV=development tsup --watch",
"dev": "pnpm run --parallel \"/^dev:(css|tsup)/\"",
"build:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css",
"pack": "npm version patch && pnpm build && npm pack",
"pack:bump": "bun scripts/bump-version.js && nr pack && echo $(pwd)/react-scan-$(node -p \"require('./package.json').version\").tgz | pbcopy",
"publint": "publint",
"test": "vitest",
"lint": "oxlint src && pnpm typecheck",
"typecheck": "tsc --noEmit"
},
"exports": {
"./package.json": "./package.json",
".": {
"production": {
"import": {
"types": "./dist/index.d.mts",
"react-server": "./dist/rsc-shim.mjs",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.mts",
"react-server": "./dist/rsc-shim.js",
"default": "./dist/index.mjs"
}
},
"development": {
"import": {
"types": "./dist/index.d.mts",
"react-server": "./dist/rsc-shim.mjs",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"react-server": "./dist/rsc-shim.js",
"default": "./dist/index.js"
}
},
"default": {
"import": {
"types": "./dist/index.d.mts",
"react-server": "./dist/rsc-shim.mjs",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"react-server": "./dist/rsc-shim.js",
"default": "./dist/index.js"
}
}
},
"./all-environments": {
"types": "./dist/core/all-environments.d.ts",
"import": "./dist/core/all-environments.mjs",
"require": "./dist/core/all-environments.js"
},
"./install-hook": {
"types": "./dist/install-hook.d.ts",
"import": "./dist/install-hook.mjs",
"require": "./dist/install-hook.js"
},
"./auto": {
"production": {
"import": {
"types": "./dist/rsc-shim.d.mts",
"react-server": "./dist/rsc-shim.mjs",
"default": "./dist/rsc-shim.mjs"
},
"require": {
"types": "./dist/rsc-shim.d.ts",
"react-server": "./dist/rsc-shim.js",
"default": "./dist/rsc-shim.js"
}
},
"development": {
"import": {
"types": "./dist/auto.d.mts",
"react-server": "./dist/rsc-shim.mjs",
"default": "./dist/auto.mjs"
},
"require": {
"types": "./dist/auto.d.ts",
"react-server": "./dist/rsc-shim.js",
"default": "./dist/auto.js"
}
}
},
"./dist/*": "./dist/*.js",
"./dist/*.js": "./dist/*.js",
"./dist/*.mjs": "./dist/*.mjs",
"./react-component-name/vite": {
"types": "./dist/react-component-name/vite.d.ts",
"import": "./dist/react-component-name/vite.mjs",
"require": "./dist/react-component-name/vite.js"
},
"./react-component-name/webpack": {
"types": "./dist/react-component-name/webpack.d.ts",
"import": "./dist/react-component-name/webpack.mjs",
"require": "./dist/react-component-name/webpack.js"
},
"./react-component-name/esbuild": {
"types": "./dist/react-component-name/esbuild.d.ts",
"import": "./dist/react-component-name/esbuild.mjs",
"require": "./dist/react-component-name/esbuild.js"
},
"./react-component-name/rspack": {
"types": "./dist/react-component-name/rspack.d.ts",
"import": "./dist/react-component-name/rspack.mjs",
"require": "./dist/react-component-name/rspack.js"
},
"./react-component-name/rolldown": {
"types": "./dist/react-component-name/rolldown.d.ts",
"import": "./dist/react-component-name/rolldown.mjs",
"require": "./dist/react-component-name/rolldown.js"
},
"./react-component-name/rollup": {
"types": "./dist/react-component-name/rollup.d.ts",
"import": "./dist/react-component-name/rollup.mjs",
"require": "./dist/react-component-name/rollup.js"
},
"./react-component-name/astro": {
"types": "./dist/react-component-name/astro.d.ts",
"import": "./dist/react-component-name/astro.mjs",
"require": "./dist/react-component-name/astro.js"
},
"./react-component-name/loader": {
"types": "./dist/react-component-name/loader.d.ts",
"import": "./dist/react-component-name/loader.mjs",
"require": "./dist/react-component-name/loader.js"
}
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"browser": "dist/auto.global.js",
"types": "dist/index.d.ts",
"typesVersions": {
"*": {
"react-component-name/vite": [
"./dist/react-component-name/vite.d.ts"
],
"react-component-name/webpack": [
"./dist/react-component-name/webpack.d.ts"
],
"react-component-name/esbuild": [
"./dist/react-component-name/esbuild.d.ts"
],
"react-component-name/rspack": [
"./dist/react-component-name/rspack.d.ts"
],
"react-component-name/rolldown": [
"./dist/react-component-name/rolldown.d.ts"
],
"react-component-name/rollup": [
"./dist/react-component-name/rollup.d.ts"
],
"react-component-name/astro": [
"./dist/react-component-name/astro.d.ts"
],
"react-component-name/loader": [
"./dist/react-component-name/loader.d.ts"
]
}
},
"bin": "bin/cli.js",
"files": [
"dist",
"bin",
"package.json",
"README.md",
"LICENSE",
"auto.d.ts"
],
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/generator": "^7.26.2",
"@babel/types": "^7.26.0",
"@preact/signals": "^1.3.1",
"@rollup/pluginutils": "^5.1.3",
"@types/node": "^20.17.9",
"bippy": "^0.5.30",
"commander": "^14.0.0",
"esbuild": "^0.25.0",
"estree-walker": "^3.0.3",
"picocolors": "^1.1.1",
"preact": "^10.25.1",
"prompts": "^2.4.2"
},
"devDependencies": {
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@remix-run/react": "*",
"@types/babel__core": "^7.20.5",
"@types/prompts": "^2.4.9",
"@types/react": "^18.0.0",
"@types/react-router": "^5.1.0",
"clsx": "^2.1.1",
"es-module-lexer": "^1.5.4",
"next": "*",
"postcss-cli": "^11.0.0",
"publint": "^0.2.12",
"react": "*",
"react-dom": "*",
"react-router": "^5.0.0",
"react-router-dom": "^5.0.0 || ^6.0.0 || ^7.0.0",
"tailwind-merge": "^2.5.5",
"terser": "^5.36.0",
"tsup": "^8.0.0",
"vitest": "^3.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"optionalDependencies": {
"unplugin": "2.1.0"
},
"publishConfig": {
"access": "public"
}
}
================================================
FILE: packages/scan/postcss.config.mjs
================================================
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import remToPx from './postcss.rem2px.mjs';
export default {
plugins: [remToPx({ baseValue: 16 }), tailwindcss, autoprefixer],
};
================================================
FILE: packages/scan/postcss.rem2px.mjs
================================================
const remToPx = (options = {}) => {
const baseValue = options.baseValue || 16;
// Improved regex that handles all rem cases including negatives
const remRegex = /(? {
// Handle all cases: calc(), min(), max(), clamp(), and regular values
return value.replace(remRegex, (_match, negative, num) => {
const pixels = Number.parseFloat(num) * baseValue;
return `${negative ? '-' : ''}${pixels}px`;
});
};
return {
postcssPlugin: 'postcss-rem-to-px',
prepare() {
return {
Once(root) {
root.walkDecls((decl) => {
if (decl.value?.includes('rem')) {
decl.value = convertRemToPx(decl.value);
}
});
},
Declaration(decl) {
if (decl.value?.includes('rem')) {
decl.value = convertRemToPx(decl.value);
}
},
AtRule: {
media: (atRule) => {
if (atRule.params?.includes('rem')) {
atRule.params = convertRemToPx(atRule.params);
}
},
},
};
},
};
};
remToPx.postcss = true;
export default remToPx;
================================================
FILE: packages/scan/scripts/bump-version.js
================================================
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
// Read the current version from scan package.json
const scanPackagePath = path.join(__dirname, '../package.json');
const scanPackage = JSON.parse(fs.readFileSync(scanPackagePath, 'utf8'));
// Bump patch version
const version = scanPackage.version.split('.');
version[2] = Number.parseInt(version[2]) + 1;
const newVersion = version.join('.');
// Update the version in package.json
scanPackage.version = newVersion;
// Write back to package.json
fs.writeFileSync(scanPackagePath, `${JSON.stringify(scanPackage, null, 2)}\n`);
// Get the tar file path
const tarFileName = `react-scan-${newVersion}.tgz`;
const tarFilePath = path.join(__dirname, '..', tarFileName);
// Copy to clipboard
execSync(`echo "${tarFilePath}" | pbcopy`);
// oxlint-disable-next-line no-console
console.log(`Bumped version to ${newVersion}`);
// oxlint-disable-next-line no-console
console.log(`Tar file path copied to clipboard: ${tarFilePath}`);
================================================
FILE: packages/scan/src/auto.ts
================================================
import './polyfills';
// Prioritize bippy side-effect
import 'bippy';
import { IS_CLIENT } from '~web/utils/constants';
import { scan } from './index';
if (IS_CLIENT) {
scan();
window.reactScan = scan;
}
export * from './core';
================================================
FILE: packages/scan/src/cli-utils.mts
================================================
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
type Framework = 'next' | 'vite' | 'tanstack' | 'webpack' | 'unknown';
type NextRouterType = 'app' | 'pages' | 'unknown';
interface ProjectInfo {
packageManager: PackageManager;
framework: Framework;
nextRouterType: NextRouterType;
projectRoot: string;
hasReactScan: boolean;
}
interface TransformResult {
success: boolean;
filePath: string;
message: string;
originalContent?: string;
newContent?: string;
noChanges?: boolean;
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
content: string;
}
const FRAMEWORK_NAMES: Record = {
next: 'Next.js',
vite: 'Vite',
tanstack: 'TanStack Start',
webpack: 'Webpack',
unknown: 'Unknown',
};
const INSTALL_COMMANDS: Record = {
npm: 'npm install -D',
yarn: 'yarn add -D',
pnpm: 'pnpm add -D',
bun: 'bun add -D',
};
// --- Templates ---
const REACT_SCAN_SCRIPT_TAG = '';
const NEXT_APP_ROUTER_SCRIPT = `{process.env.NODE_ENV === "development" && (
)}`;
const NEXT_PAGES_ROUTER_SCRIPT = `{process.env.NODE_ENV === "development" && (
)}`;
const VITE_SCRIPT = ``;
const WEBPACK_IMPORT = `if (process.env.NODE_ENV === "development") {
import("react-scan");
}`;
// --- Detection ---
const detectPackageManager = (projectRoot: string): PackageManager => {
if (existsSync(join(projectRoot, 'bun.lockb')) || existsSync(join(projectRoot, 'bun.lock'))) return 'bun';
if (existsSync(join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm';
if (existsSync(join(projectRoot, 'yarn.lock'))) return 'yarn';
return 'npm';
};
const detectFramework = (projectRoot: string): Framework => {
const packageJsonPath = join(projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) return 'unknown';
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (allDeps['next']) return 'next';
if (allDeps['@tanstack/react-start']) return 'tanstack';
if (allDeps['vite']) return 'vite';
if (allDeps['webpack'] || allDeps['react-scripts']) return 'webpack';
return 'unknown';
} catch {
return 'unknown';
}
};
const detectNextRouterType = (projectRoot: string): NextRouterType => {
if (existsSync(join(projectRoot, 'app')) || existsSync(join(projectRoot, 'src', 'app'))) return 'app';
if (existsSync(join(projectRoot, 'pages')) || existsSync(join(projectRoot, 'src', 'pages'))) return 'pages';
return 'unknown';
};
const detectProject = (cwd: string): ProjectInfo => {
const packageManager = detectPackageManager(cwd);
const framework = detectFramework(cwd);
const nextRouterType = framework === 'next' ? detectNextRouterType(cwd) : 'unknown';
const packageJsonPath = join(cwd, 'package.json');
let hasReactScan = false;
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
hasReactScan = Boolean(allDeps['react-scan']);
} catch { /* */ }
}
return {
packageManager,
framework,
nextRouterType,
projectRoot: cwd,
hasReactScan,
};
};
// --- File Finding ---
const findLayoutFile = (
projectRoot: string,
routerType: NextRouterType,
): string | null => {
if (routerType === 'app') {
const candidates = [
join(projectRoot, 'app', 'layout.tsx'),
join(projectRoot, 'app', 'layout.jsx'),
join(projectRoot, 'app', 'layout.js'),
join(projectRoot, 'src', 'app', 'layout.tsx'),
join(projectRoot, 'src', 'app', 'layout.jsx'),
join(projectRoot, 'src', 'app', 'layout.js'),
];
return candidates.find(existsSync) ?? null;
}
if (routerType === 'pages') {
const candidates = [
join(projectRoot, 'pages', '_document.tsx'),
join(projectRoot, 'pages', '_document.jsx'),
join(projectRoot, 'pages', '_document.js'),
join(projectRoot, 'src', 'pages', '_document.tsx'),
join(projectRoot, 'src', 'pages', '_document.jsx'),
join(projectRoot, 'src', 'pages', '_document.js'),
];
return candidates.find(existsSync) ?? null;
}
return null;
};
const findIndexHtml = (projectRoot: string): string | null => {
const candidates = [
join(projectRoot, 'index.html'),
join(projectRoot, 'public', 'index.html'),
join(projectRoot, 'src', 'index.html'),
];
return candidates.find(existsSync) ?? null;
};
const findEntryFile = (projectRoot: string): string | null => {
const candidates = [
join(projectRoot, 'src', 'index.tsx'),
join(projectRoot, 'src', 'index.ts'),
join(projectRoot, 'src', 'index.jsx'),
join(projectRoot, 'src', 'index.js'),
join(projectRoot, 'src', 'main.tsx'),
join(projectRoot, 'src', 'main.ts'),
join(projectRoot, 'src', 'main.jsx'),
join(projectRoot, 'src', 'main.js'),
];
return candidates.find(existsSync) ?? null;
};
const hasReactScanCode = (content: string): boolean => {
return content.includes('react-scan') || content.includes('react_scan');
};
// --- Transform ---
const transformNextAppRouter = (
projectRoot: string,
routerType: NextRouterType,
): TransformResult => {
const layoutPath = findLayoutFile(projectRoot, routerType);
if (!layoutPath) {
return {
success: false,
filePath: '',
message: 'Could not find app/layout.tsx',
};
}
const originalContent = readFileSync(layoutPath, 'utf-8');
if (hasReactScanCode(originalContent)) {
return {
success: true,
filePath: layoutPath,
message: 'React Scan is already installed.',
noChanges: true,
};
}
let newContent = originalContent;
const headOpenMatch = newContent.match(/]*>/);
if (headOpenMatch) {
const injection = `\n ${NEXT_APP_ROUTER_SCRIPT}\n`;
newContent = newContent.replace(
headOpenMatch[0],
`${headOpenMatch[0]}${injection}`,
);
} else {
const bodyMatch = newContent.match(//);
if (bodyMatch) {
const injection = `\n ${NEXT_APP_ROUTER_SCRIPT}`;
newContent = newContent.replace(
bodyMatch[0],
`${bodyMatch[0]}${injection}`,
);
}
}
return {
success: true,
filePath: layoutPath,
message: 'Success',
originalContent,
newContent,
};
};
const transformNextPagesRouter = (
projectRoot: string,
routerType: NextRouterType,
): TransformResult => {
const documentPath = findLayoutFile(projectRoot, routerType);
if (!documentPath) {
return {
success: false,
filePath: '',
message: 'Could not find pages/_document.tsx',
};
}
const originalContent = readFileSync(documentPath, 'utf-8');
if (hasReactScanCode(originalContent)) {
return {
success: true,
filePath: documentPath,
message: 'React Scan is already installed.',
noChanges: true,
};
}
let newContent = originalContent;
const injection = `\n ${NEXT_PAGES_ROUTER_SCRIPT}`;
const headMatch = newContent.match(/([\s\S]*?)<\/Head>/);
if (headMatch) {
newContent = newContent.replace('', `${injection}`);
} else {
const selfClosingHeadMatch = newContent.match(/ /);
if (selfClosingHeadMatch) {
newContent = newContent.replace(
selfClosingHeadMatch[0],
`${injection}\n `,
);
}
}
if (newContent === originalContent) {
return {
success: false,
filePath: documentPath,
message:
'Could not find component in _document file to inject React Scan script.',
};
}
return {
success: true,
filePath: documentPath,
message: 'Success',
originalContent,
newContent,
};
};
const transformVite = (projectRoot: string): TransformResult => {
const indexHtml = findIndexHtml(projectRoot);
if (!indexHtml) {
return {
success: false,
filePath: '',
message: 'Could not find index.html',
};
}
const originalContent = readFileSync(indexHtml, 'utf-8');
if (hasReactScanCode(originalContent)) {
return {
success: true,
filePath: indexHtml,
message: 'React Scan is already installed.',
noChanges: true,
};
}
const headOpenMatch = originalContent.match(/]*>/);
if (!headOpenMatch) {
return {
success: false,
filePath: indexHtml,
message: 'Could not find tag in index.html',
};
}
const newContent = originalContent.replace(
headOpenMatch[0],
`${headOpenMatch[0]}\n ${VITE_SCRIPT}`,
);
return {
success: true,
filePath: indexHtml,
message: 'Success',
originalContent,
newContent,
};
};
const transformWebpack = (projectRoot: string): TransformResult => {
const indexHtml = findIndexHtml(projectRoot);
if (indexHtml) {
const originalContent = readFileSync(indexHtml, 'utf-8');
if (hasReactScanCode(originalContent)) {
return {
success: true,
filePath: indexHtml,
message: 'React Scan is already installed.',
noChanges: true,
};
}
const headOpenMatch = originalContent.match(/]*>/);
if (!headOpenMatch) {
return {
success: false,
filePath: indexHtml,
message: 'Could not find tag in index.html',
};
}
const newContent = originalContent.replace(
headOpenMatch[0],
`${headOpenMatch[0]}\n ${REACT_SCAN_SCRIPT_TAG}`,
);
return {
success: true,
filePath: indexHtml,
message: 'Success',
originalContent,
newContent,
};
}
const entryFile = findEntryFile(projectRoot);
if (!entryFile) {
return {
success: false,
filePath: '',
message: 'Could not find entry file or index.html',
};
}
const originalContent = readFileSync(entryFile, 'utf-8');
if (hasReactScanCode(originalContent)) {
return {
success: true,
filePath: entryFile,
message: 'React Scan is already installed.',
noChanges: true,
};
}
const newContent = `${WEBPACK_IMPORT}\n\n${originalContent}`;
return {
success: true,
filePath: entryFile,
message: 'Success',
originalContent,
newContent,
};
};
const previewTransform = (
projectRoot: string,
framework: Framework,
nextRouterType: NextRouterType,
): TransformResult => {
switch (framework) {
case 'next':
return nextRouterType === 'pages'
? transformNextPagesRouter(projectRoot, nextRouterType)
: transformNextAppRouter(projectRoot, nextRouterType);
case 'vite':
return transformVite(projectRoot);
case 'webpack':
return transformWebpack(projectRoot);
case 'tanstack':
case 'unknown':
default:
return {
success: false,
filePath: '',
message: `Framework "${framework}" is not yet supported by automatic setup. Visit https://github.com/aidenybai/react-scan#install for manual setup.`,
};
}
};
// --- Diff ---
const generateDiff = (original: string, updated: string): DiffLine[] => {
const originalLines = original.split('\n');
const newLines = updated.split('\n');
const diff: DiffLine[] = [];
let originalIdx = 0;
let newIdx = 0;
while (originalIdx < originalLines.length || newIdx < newLines.length) {
const originalLine = originalLines[originalIdx];
const newLine = newLines[newIdx];
if (originalLine === newLine) {
diff.push({ type: 'unchanged', content: originalLine });
originalIdx++;
newIdx++;
} else if (originalLine === undefined) {
diff.push({ type: 'added', content: newLine });
newIdx++;
} else if (newLine === undefined) {
diff.push({ type: 'removed', content: originalLine });
originalIdx++;
} else {
const originalInNew = newLines.indexOf(originalLine, newIdx);
const newInOriginal = originalLines.indexOf(newLine, originalIdx);
if (originalInNew !== -1 && (newInOriginal === -1 || originalInNew - newIdx < newInOriginal - originalIdx)) {
while (newIdx < originalInNew) {
diff.push({ type: 'added', content: newLines[newIdx] });
newIdx++;
}
} else if (newInOriginal !== -1) {
while (originalIdx < newInOriginal) {
diff.push({ type: 'removed', content: originalLines[originalIdx] });
originalIdx++;
}
} else {
diff.push({ type: 'removed', content: originalLine });
diff.push({ type: 'added', content: newLine });
originalIdx++;
newIdx++;
}
}
}
return diff;
};
export {
type DiffLine,
type Framework,
type NextRouterType,
type PackageManager,
type ProjectInfo,
type TransformResult,
FRAMEWORK_NAMES,
INSTALL_COMMANDS,
NEXT_APP_ROUTER_SCRIPT,
NEXT_PAGES_ROUTER_SCRIPT,
REACT_SCAN_SCRIPT_TAG,
VITE_SCRIPT,
WEBPACK_IMPORT,
detectFramework,
detectNextRouterType,
detectPackageManager,
detectProject,
findEntryFile,
findIndexHtml,
findLayoutFile,
generateDiff,
hasReactScanCode,
previewTransform,
transformNextAppRouter,
transformNextPagesRouter,
transformVite,
transformWebpack,
};
================================================
FILE: packages/scan/src/cli-utils.test.mts
================================================
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
NEXT_APP_ROUTER_SCRIPT,
NEXT_PAGES_ROUTER_SCRIPT,
REACT_SCAN_SCRIPT_TAG,
VITE_SCRIPT,
WEBPACK_IMPORT,
detectFramework,
detectNextRouterType,
detectPackageManager,
detectProject,
findEntryFile,
findIndexHtml,
findLayoutFile,
generateDiff,
hasReactScanCode,
previewTransform,
transformNextAppRouter,
transformNextPagesRouter,
transformVite,
transformWebpack,
} from './cli-utils.mjs';
let tempDirectory: string;
beforeEach(() => {
tempDirectory = mkdtempSync(join(tmpdir(), 'react-scan-cli-test-'));
});
afterEach(() => {
rmSync(tempDirectory, { recursive: true, force: true });
});
const writePackageJson = (
directory: string,
dependencies: Record = {},
devDependencies: Record = {},
) => {
writeFileSync(
join(directory, 'package.json'),
JSON.stringify({ dependencies, devDependencies }),
);
};
// --- detectPackageManager ---
describe('detectPackageManager', () => {
it('returns bun when bun.lockb exists', () => {
writeFileSync(join(tempDirectory, 'bun.lockb'), '');
expect(detectPackageManager(tempDirectory)).toBe('bun');
});
it('returns bun when bun.lock exists', () => {
writeFileSync(join(tempDirectory, 'bun.lock'), '');
expect(detectPackageManager(tempDirectory)).toBe('bun');
});
it('returns pnpm when pnpm-lock.yaml exists', () => {
writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), '');
expect(detectPackageManager(tempDirectory)).toBe('pnpm');
});
it('returns yarn when yarn.lock exists', () => {
writeFileSync(join(tempDirectory, 'yarn.lock'), '');
expect(detectPackageManager(tempDirectory)).toBe('yarn');
});
it('defaults to npm when no lock file exists', () => {
expect(detectPackageManager(tempDirectory)).toBe('npm');
});
it('prefers bun over pnpm when both lock files exist', () => {
writeFileSync(join(tempDirectory, 'bun.lockb'), '');
writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), '');
expect(detectPackageManager(tempDirectory)).toBe('bun');
});
it('prefers pnpm over yarn when both lock files exist', () => {
writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), '');
writeFileSync(join(tempDirectory, 'yarn.lock'), '');
expect(detectPackageManager(tempDirectory)).toBe('pnpm');
});
});
// --- detectFramework ---
describe('detectFramework', () => {
it('detects Next.js from dependencies', () => {
writePackageJson(tempDirectory, { next: '^14.0.0' });
expect(detectFramework(tempDirectory)).toBe('next');
});
it('detects Next.js from devDependencies', () => {
writePackageJson(tempDirectory, {}, { next: '^14.0.0' });
expect(detectFramework(tempDirectory)).toBe('next');
});
it('detects Vite from dependencies', () => {
writePackageJson(tempDirectory, {}, { vite: '^5.0.0' });
expect(detectFramework(tempDirectory)).toBe('vite');
});
it('detects TanStack Start from dependencies', () => {
writePackageJson(tempDirectory, { '@tanstack/react-start': '^1.0.0' });
expect(detectFramework(tempDirectory)).toBe('tanstack');
});
it('detects Webpack from dependencies', () => {
writePackageJson(tempDirectory, {}, { webpack: '^5.0.0' });
expect(detectFramework(tempDirectory)).toBe('webpack');
});
it('detects Webpack via react-scripts', () => {
writePackageJson(tempDirectory, { 'react-scripts': '^5.0.0' });
expect(detectFramework(tempDirectory)).toBe('webpack');
});
it('returns unknown when no framework is detected', () => {
writePackageJson(tempDirectory, { react: '^18.0.0' });
expect(detectFramework(tempDirectory)).toBe('unknown');
});
it('returns unknown when no package.json exists', () => {
expect(detectFramework(tempDirectory)).toBe('unknown');
});
it('returns unknown when package.json is malformed', () => {
writeFileSync(join(tempDirectory, 'package.json'), 'not-json');
expect(detectFramework(tempDirectory)).toBe('unknown');
});
it('prefers Next.js over Vite when both are present', () => {
writePackageJson(tempDirectory, { next: '^14.0.0' }, { vite: '^5.0.0' });
expect(detectFramework(tempDirectory)).toBe('next');
});
});
// --- detectNextRouterType ---
describe('detectNextRouterType', () => {
it('detects app router from root app directory', () => {
mkdirSync(join(tempDirectory, 'app'));
expect(detectNextRouterType(tempDirectory)).toBe('app');
});
it('detects app router from src/app directory', () => {
mkdirSync(join(tempDirectory, 'src', 'app'), { recursive: true });
expect(detectNextRouterType(tempDirectory)).toBe('app');
});
it('detects pages router from root pages directory', () => {
mkdirSync(join(tempDirectory, 'pages'));
expect(detectNextRouterType(tempDirectory)).toBe('pages');
});
it('detects pages router from src/pages directory', () => {
mkdirSync(join(tempDirectory, 'src', 'pages'), { recursive: true });
expect(detectNextRouterType(tempDirectory)).toBe('pages');
});
it('returns unknown when no router directories exist', () => {
expect(detectNextRouterType(tempDirectory)).toBe('unknown');
});
it('prefers app router when both app and pages directories exist', () => {
mkdirSync(join(tempDirectory, 'app'));
mkdirSync(join(tempDirectory, 'pages'));
expect(detectNextRouterType(tempDirectory)).toBe('app');
});
});
// --- detectProject ---
describe('detectProject', () => {
it('detects a Next.js app router project with pnpm', () => {
writePackageJson(tempDirectory, { next: '^14.0.0', react: '^18.0.0' });
writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), '');
mkdirSync(join(tempDirectory, 'app'));
const project = detectProject(tempDirectory);
expect(project.packageManager).toBe('pnpm');
expect(project.framework).toBe('next');
expect(project.nextRouterType).toBe('app');
expect(project.projectRoot).toBe(tempDirectory);
expect(project.hasReactScan).toBe(false);
});
it('detects hasReactScan from dependencies', () => {
writePackageJson(tempDirectory, { 'react-scan': '^0.4.0', vite: '^5.0.0' });
const project = detectProject(tempDirectory);
expect(project.hasReactScan).toBe(true);
});
it('detects hasReactScan from devDependencies', () => {
writePackageJson(tempDirectory, { vite: '^5.0.0' }, { 'react-scan': '^0.4.0' });
const project = detectProject(tempDirectory);
expect(project.hasReactScan).toBe(true);
});
it('sets nextRouterType to unknown for non-Next.js frameworks', () => {
writePackageJson(tempDirectory, {}, { vite: '^5.0.0' });
mkdirSync(join(tempDirectory, 'app'));
const project = detectProject(tempDirectory);
expect(project.nextRouterType).toBe('unknown');
});
});
// --- hasReactScanCode ---
describe('hasReactScanCode', () => {
it('detects react-scan in content', () => {
expect(hasReactScanCode('import("react-scan")')).toBe(true);
});
it('detects react_scan in content', () => {
expect(hasReactScanCode('window.react_scan = true')).toBe(true);
});
it('returns false when not present', () => {
expect(hasReactScanCode('import React from "react"')).toBe(false);
});
it('detects react-scan in script tag', () => {
expect(hasReactScanCode('')).toBe(true);
});
});
// --- findLayoutFile ---
describe('findLayoutFile', () => {
it('finds app/layout.tsx for app router', () => {
mkdirSync(join(tempDirectory, 'app'));
const layoutPath = join(tempDirectory, 'app', 'layout.tsx');
writeFileSync(layoutPath, '');
expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath);
});
it('finds src/app/layout.tsx for app router', () => {
mkdirSync(join(tempDirectory, 'src', 'app'), { recursive: true });
const layoutPath = join(tempDirectory, 'src', 'app', 'layout.tsx');
writeFileSync(layoutPath, '');
expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath);
});
it('finds app/layout.jsx for app router', () => {
mkdirSync(join(tempDirectory, 'app'));
const layoutPath = join(tempDirectory, 'app', 'layout.jsx');
writeFileSync(layoutPath, '');
expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath);
});
it('finds pages/_document.tsx for pages router', () => {
mkdirSync(join(tempDirectory, 'pages'));
const documentPath = join(tempDirectory, 'pages', '_document.tsx');
writeFileSync(documentPath, '');
expect(findLayoutFile(tempDirectory, 'pages')).toBe(documentPath);
});
it('finds src/pages/_document.tsx for pages router', () => {
mkdirSync(join(tempDirectory, 'src', 'pages'), { recursive: true });
const documentPath = join(tempDirectory, 'src', 'pages', '_document.tsx');
writeFileSync(documentPath, '');
expect(findLayoutFile(tempDirectory, 'pages')).toBe(documentPath);
});
it('returns null when no layout file exists for app router', () => {
expect(findLayoutFile(tempDirectory, 'app')).toBeNull();
});
it('returns null when no document file exists for pages router', () => {
expect(findLayoutFile(tempDirectory, 'pages')).toBeNull();
});
it('returns null for unknown router type', () => {
expect(findLayoutFile(tempDirectory, 'unknown')).toBeNull();
});
});
// --- findIndexHtml ---
describe('findIndexHtml', () => {
it('finds root index.html', () => {
const indexPath = join(tempDirectory, 'index.html');
writeFileSync(indexPath, '');
expect(findIndexHtml(tempDirectory)).toBe(indexPath);
});
it('finds public/index.html', () => {
mkdirSync(join(tempDirectory, 'public'));
const indexPath = join(tempDirectory, 'public', 'index.html');
writeFileSync(indexPath, '');
expect(findIndexHtml(tempDirectory)).toBe(indexPath);
});
it('finds src/index.html', () => {
mkdirSync(join(tempDirectory, 'src'));
const indexPath = join(tempDirectory, 'src', 'index.html');
writeFileSync(indexPath, '');
expect(findIndexHtml(tempDirectory)).toBe(indexPath);
});
it('prefers root index.html over public/index.html', () => {
const rootPath = join(tempDirectory, 'index.html');
writeFileSync(rootPath, '');
mkdirSync(join(tempDirectory, 'public'));
writeFileSync(join(tempDirectory, 'public', 'index.html'), '');
expect(findIndexHtml(tempDirectory)).toBe(rootPath);
});
it('returns null when no index.html exists', () => {
expect(findIndexHtml(tempDirectory)).toBeNull();
});
});
// --- findEntryFile ---
describe('findEntryFile', () => {
it('finds src/index.tsx', () => {
mkdirSync(join(tempDirectory, 'src'));
const entryPath = join(tempDirectory, 'src', 'index.tsx');
writeFileSync(entryPath, '');
expect(findEntryFile(tempDirectory)).toBe(entryPath);
});
it('finds src/main.tsx', () => {
mkdirSync(join(tempDirectory, 'src'));
const entryPath = join(tempDirectory, 'src', 'main.tsx');
writeFileSync(entryPath, '');
expect(findEntryFile(tempDirectory)).toBe(entryPath);
});
it('finds src/index.js', () => {
mkdirSync(join(tempDirectory, 'src'));
const entryPath = join(tempDirectory, 'src', 'index.js');
writeFileSync(entryPath, '');
expect(findEntryFile(tempDirectory)).toBe(entryPath);
});
it('prefers src/index.tsx over src/main.tsx', () => {
mkdirSync(join(tempDirectory, 'src'));
const indexPath = join(tempDirectory, 'src', 'index.tsx');
writeFileSync(indexPath, '');
writeFileSync(join(tempDirectory, 'src', 'main.tsx'), '');
expect(findEntryFile(tempDirectory)).toBe(indexPath);
});
it('returns null when no entry file exists', () => {
expect(findEntryFile(tempDirectory)).toBeNull();
});
});
// --- transformNextAppRouter ---
describe('transformNextAppRouter', () => {
const LAYOUT_WITH_BODY = `export default function RootLayout({ children }) {
return (
{children}
);
}`;
const LAYOUT_WITH_HEAD = `export default function RootLayout({ children }) {
return (
App
{children}
);
}`;
it('returns failure when no layout file exists', () => {
const result = transformNextAppRouter(tempDirectory, 'app');
expect(result.success).toBe(false);
expect(result.message).toContain('Could not find');
});
it('injects script after body tag when no head tag exists', () => {
mkdirSync(join(tempDirectory, 'app'));
writeFileSync(join(tempDirectory, 'app', 'layout.tsx'), LAYOUT_WITH_BODY);
const result = transformNextAppRouter(tempDirectory, 'app');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
expect(result.newContent).toContain('');
});
it('reports already installed when react-scan is in content', () => {
mkdirSync(join(tempDirectory, 'app'));
writeFileSync(
join(tempDirectory, 'app', 'layout.tsx'),
'import "react-scan";\n' + LAYOUT_WITH_BODY,
);
const result = transformNextAppRouter(tempDirectory, 'app');
expect(result.success).toBe(true);
expect(result.noChanges).toBe(true);
expect(result.message).toContain('already installed');
});
it('preserves original content', () => {
mkdirSync(join(tempDirectory, 'app'));
writeFileSync(join(tempDirectory, 'app', 'layout.tsx'), LAYOUT_WITH_BODY);
const result = transformNextAppRouter(tempDirectory, 'app');
expect(result.originalContent).toBe(LAYOUT_WITH_BODY);
});
});
// --- transformNextPagesRouter ---
describe('transformNextPagesRouter', () => {
const DOCUMENT_WITH_HEAD = `import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
);
}`;
it('returns failure when no _document file exists', () => {
const result = transformNextPagesRouter(tempDirectory, 'pages');
expect(result.success).toBe(false);
expect(result.message).toContain('Could not find');
});
it('injects script inside Head tag', () => {
mkdirSync(join(tempDirectory, 'pages'));
writeFileSync(join(tempDirectory, 'pages', '_document.tsx'), DOCUMENT_WITH_HEAD);
const result = transformNextPagesRouter(tempDirectory, 'pages');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
expect(result.newContent).toContain('');
});
it('reports already installed when react-scan is in content', () => {
mkdirSync(join(tempDirectory, 'pages'));
writeFileSync(
join(tempDirectory, 'pages', '_document.tsx'),
DOCUMENT_WITH_HEAD.replace('', ''),
);
const result = transformNextPagesRouter(tempDirectory, 'pages');
expect(result.success).toBe(true);
expect(result.noChanges).toBe(true);
});
});
// --- transformVite ---
describe('transformVite', () => {
const VITE_INDEX_HTML = `
Vite App
`;
it('returns failure when no index.html exists', () => {
const result = transformVite(tempDirectory);
expect(result.success).toBe(false);
expect(result.message).toContain('Could not find index.html');
});
it('injects script inside head tag', () => {
writeFileSync(join(tempDirectory, 'index.html'), VITE_INDEX_HTML);
const result = transformVite(tempDirectory);
expect(result.success).toBe(true);
expect(result.newContent).toContain(VITE_SCRIPT);
expect(result.newContent).toContain('');
});
it('reports already installed when react-scan is in content', () => {
writeFileSync(
join(tempDirectory, 'index.html'),
VITE_INDEX_HTML.replace('', `\n ${VITE_SCRIPT}`),
);
const result = transformVite(tempDirectory);
expect(result.success).toBe(true);
expect(result.noChanges).toBe(true);
});
it('preserves rest of the html', () => {
writeFileSync(join(tempDirectory, 'index.html'), VITE_INDEX_HTML);
const result = transformVite(tempDirectory);
expect(result.newContent).toContain('
');
expect(result.newContent).toContain('src="/src/main.tsx"');
});
});
// --- transformWebpack ---
describe('transformWebpack', () => {
const WEBPACK_INDEX_HTML = `
React App
`;
const WEBPACK_ENTRY = `import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render( );`;
it('injects script tag into index.html when it exists', () => {
mkdirSync(join(tempDirectory, 'public'));
writeFileSync(join(tempDirectory, 'public', 'index.html'), WEBPACK_INDEX_HTML);
const result = transformWebpack(tempDirectory);
expect(result.success).toBe(true);
expect(result.newContent).toContain(REACT_SCAN_SCRIPT_TAG);
});
it('falls back to entry file import when no index.html exists', () => {
mkdirSync(join(tempDirectory, 'src'));
writeFileSync(join(tempDirectory, 'src', 'index.tsx'), WEBPACK_ENTRY);
const result = transformWebpack(tempDirectory);
expect(result.success).toBe(true);
expect(result.newContent).toContain(WEBPACK_IMPORT);
expect(result.newContent).toContain(WEBPACK_ENTRY);
});
it('returns failure when no index.html or entry file exists', () => {
const result = transformWebpack(tempDirectory);
expect(result.success).toBe(false);
expect(result.message).toContain('Could not find');
});
it('reports already installed via index.html', () => {
mkdirSync(join(tempDirectory, 'public'));
writeFileSync(
join(tempDirectory, 'public', 'index.html'),
WEBPACK_INDEX_HTML.replace('', `\n ${REACT_SCAN_SCRIPT_TAG}`),
);
const result = transformWebpack(tempDirectory);
expect(result.success).toBe(true);
expect(result.noChanges).toBe(true);
});
it('reports already installed via entry file', () => {
mkdirSync(join(tempDirectory, 'src'));
writeFileSync(
join(tempDirectory, 'src', 'index.tsx'),
`import("react-scan");\n${WEBPACK_ENTRY}`,
);
const result = transformWebpack(tempDirectory);
expect(result.success).toBe(true);
expect(result.noChanges).toBe(true);
});
});
// --- previewTransform ---
describe('previewTransform', () => {
it('routes to Next.js app router transform', () => {
mkdirSync(join(tempDirectory, 'app'));
writeFileSync(
join(tempDirectory, 'app', 'layout.tsx'),
'',
);
const result = previewTransform(tempDirectory, 'next', 'app');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
});
it('routes to Next.js pages router transform', () => {
mkdirSync(join(tempDirectory, 'pages'));
writeFileSync(
join(tempDirectory, 'pages', '_document.tsx'),
'',
);
const result = previewTransform(tempDirectory, 'next', 'pages');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
});
it('routes to Vite transform', () => {
writeFileSync(
join(tempDirectory, 'index.html'),
'',
);
const result = previewTransform(tempDirectory, 'vite', 'unknown');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
});
it('routes to Webpack transform', () => {
mkdirSync(join(tempDirectory, 'public'));
writeFileSync(
join(tempDirectory, 'public', 'index.html'),
'',
);
const result = previewTransform(tempDirectory, 'webpack', 'unknown');
expect(result.success).toBe(true);
expect(result.newContent).toContain('react-scan');
});
it('returns failure for tanstack framework', () => {
const result = previewTransform(tempDirectory, 'tanstack', 'unknown');
expect(result.success).toBe(false);
expect(result.message).toContain('not yet supported');
});
it('returns failure for unknown framework', () => {
const result = previewTransform(tempDirectory, 'unknown', 'unknown');
expect(result.success).toBe(false);
expect(result.message).toContain('not yet supported');
});
});
// --- generateDiff ---
describe('generateDiff', () => {
it('returns empty diff for identical strings', () => {
const diff = generateDiff('hello\nworld', 'hello\nworld');
expect(diff).toEqual([
{ type: 'unchanged', content: 'hello' },
{ type: 'unchanged', content: 'world' },
]);
});
it('detects added lines', () => {
const diff = generateDiff('line1\nline3', 'line1\nline2\nline3');
const addedLines = diff.filter((diffLine) => diffLine.type === 'added');
expect(addedLines.length).toBeGreaterThan(0);
expect(addedLines.some((diffLine) => diffLine.content === 'line2')).toBe(true);
});
it('detects removed lines', () => {
const diff = generateDiff('line1\nline2\nline3', 'line1\nline3');
const removedLines = diff.filter((diffLine) => diffLine.type === 'removed');
expect(removedLines.length).toBeGreaterThan(0);
expect(removedLines.some((diffLine) => diffLine.content === 'line2')).toBe(true);
});
it('detects replaced lines', () => {
const diff = generateDiff('hello', 'goodbye');
expect(diff).toEqual([
{ type: 'removed', content: 'hello' },
{ type: 'added', content: 'goodbye' },
]);
});
it('handles empty original', () => {
const diff = generateDiff('', 'new line');
expect(diff).toEqual([
{ type: 'removed', content: '' },
{ type: 'added', content: 'new line' },
]);
});
it('handles empty updated', () => {
const diff = generateDiff('old line', '');
expect(diff).toEqual([
{ type: 'removed', content: 'old line' },
{ type: 'added', content: '' },
]);
});
it('handles multi-line additions in the middle', () => {
const original = '\n';
const updated = '\n \n';
const diff = generateDiff(original, updated);
const addedLines = diff.filter((diffLine) => diffLine.type === 'added');
expect(addedLines.length).toBe(1);
expect(addedLines[0].content).toContain('react-scan');
});
});
================================================
FILE: packages/scan/src/cli.mts
================================================
import { execSync } from 'node:child_process';
import { existsSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { Command } from 'commander';
import pc from 'picocolors';
import prompts from 'prompts';
import {
type DiffLine,
type PackageManager,
FRAMEWORK_NAMES,
INSTALL_COMMANDS,
detectProject,
generateDiff,
previewTransform,
} from './cli-utils.mjs';
const VERSION = process.env.NPM_PACKAGE_VERSION ?? '0.0.0';
// --- Diff ---
const printDiff = (filePath: string, original: string, updated: string): void => {
const diff = generateDiff(original, updated);
const contextLines = 3;
const changedIndices = diff
.map((line: DiffLine, i: number) => (line.type !== 'unchanged' ? i : -1))
.filter((i: number) => i !== -1);
if (changedIndices.length === 0) {
console.log(pc.dim(' No changes'));
return;
}
console.log(`\n${pc.bold(`File: ${filePath}`)}`);
console.log(pc.dim('─'.repeat(60)));
let lastPrintedIdx = -1;
for (const changedIdx of changedIndices) {
const start = Math.max(0, changedIdx - contextLines);
const end = Math.min(diff.length - 1, changedIdx + contextLines);
if (start > lastPrintedIdx + 1 && lastPrintedIdx !== -1) {
console.log(pc.dim(' ...'));
}
for (let i = Math.max(start, lastPrintedIdx + 1); i <= end; i++) {
const line = diff[i];
if (line.type === 'added') {
console.log(pc.green(`+ ${line.content}`));
} else if (line.type === 'removed') {
console.log(pc.red(`- ${line.content}`));
} else {
console.log(pc.dim(` ${line.content}`));
}
lastPrintedIdx = i;
}
}
console.log(pc.dim('─'.repeat(60)));
};
// --- Install ---
const installPackages = (
packages: string[],
packageManager: PackageManager,
projectRoot: string,
): void => {
if (packages.length === 0) return;
const command = `${INSTALL_COMMANDS[packageManager]} ${packages.join(' ')}`;
console.log(pc.dim(` Running: ${command}\n`));
execSync(command, {
cwd: projectRoot,
stdio: 'inherit',
});
};
// --- Main ---
const program = new Command()
.name('react-scan')
.description('React Scan CLI')
.version(VERSION);
program
.command('init')
.description('Set up React Scan in your project')
.option('-y, --yes', 'skip confirmation prompts', false)
.option('-c, --cwd ', 'working directory', process.cwd())
.option('--skip-install', 'skip package installation', false)
.action(async (opts) => {
console.log(`\n${pc.magenta('[·]')} ${pc.bold('React Scan')} ${pc.dim(`v${VERSION}`)}\n`);
try {
const cwd = resolve(opts.cwd);
if (!existsSync(cwd)) {
console.error(pc.red(`Directory does not exist: ${cwd}`));
process.exit(1);
}
if (!existsSync(join(cwd, 'package.json'))) {
console.error(pc.red('No package.json found. Run this command from a project root.'));
process.exit(1);
}
console.log(pc.dim(' Detecting project...\n'));
const project = detectProject(cwd);
if (project.framework === 'unknown') {
console.error(pc.red(' Could not detect a supported framework.'));
console.log(pc.dim(' React Scan supports Next.js, Vite, and Webpack projects.'));
console.log(pc.dim(' Visit https://github.com/aidenybai/react-scan#install for manual setup.\n'));
process.exit(1);
}
console.log(` Framework: ${pc.cyan(FRAMEWORK_NAMES[project.framework])}`);
if (project.framework === 'next') {
console.log(` Router: ${pc.cyan(project.nextRouterType === 'app' ? 'App Router' : 'Pages Router')}`);
}
console.log(` Package manager: ${pc.cyan(project.packageManager)}`);
console.log();
if (project.hasReactScan) {
console.log(pc.green(' React Scan is already installed in package.json.'));
console.log(pc.dim(' Checking if code setup is needed...\n'));
}
const result = previewTransform(cwd, project.framework, project.nextRouterType);
if (!result.success) {
console.error(pc.red(` ${result.message}\n`));
process.exit(1);
}
const hasCodeChanges = !result.noChanges && result.originalContent && result.newContent;
if (hasCodeChanges) {
printDiff(
relative(cwd, result.filePath),
result.originalContent!,
result.newContent!,
);
console.log();
console.log(pc.yellow(' Auto-detection may not be 100% accurate.'));
console.log(pc.yellow(' Please verify the changes before committing.\n'));
if (!opts.yes) {
const { proceed } = await prompts({
type: 'confirm',
name: 'proceed',
message: 'Apply these changes?',
initial: true,
});
if (!proceed) {
console.log(pc.dim('\n Changes cancelled.\n'));
process.exit(0);
}
}
}
if (!opts.skipInstall && !project.hasReactScan) {
console.log(pc.dim('\n Installing react-scan...\n'));
installPackages(['react-scan'], project.packageManager, cwd);
console.log();
}
if (hasCodeChanges) {
writeFileSync(result.filePath, result.newContent!, 'utf-8');
console.log(pc.green(` Updated ${relative(cwd, result.filePath)}`));
}
if (!hasCodeChanges && project.hasReactScan) {
console.log(pc.green(' React Scan is already set up in your project.\n'));
process.exit(0);
}
console.log();
console.log(`${pc.green(' Success!')} React Scan has been installed.`);
console.log(pc.dim(' You may now start your development server.\n'));
} catch (error) {
console.error(pc.red(`\n Error: ${error instanceof Error ? error.message : String(error)}\n`));
process.exit(1);
}
});
program.parse();
================================================
FILE: packages/scan/src/core/all-environments.ts
================================================
import { ReactScanInternals, scan as innerScan } from '.';
export const scan = /*#__PURE__*/ (...params: Parameters) => {
if (typeof window !== 'undefined') {
ReactScanInternals.runInAllEnvironments = true;
innerScan(...params);
}
};
================================================
FILE: packages/scan/src/core/fast-serialize.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { fastSerialize } from '~core/instrumentation';
describe('fastSerialize', () => {
it('serializes null', () => {
expect(fastSerialize(null)).toBe('null');
});
it('serializes undefined', () => {
expect(fastSerialize(undefined)).toBe('undefined');
});
it('serializes strings', () => {
expect(fastSerialize('hello')).toBe('hello');
expect(fastSerialize('')).toBe('');
});
it('serializes numbers', () => {
expect(fastSerialize(42)).toBe('42');
expect(fastSerialize(0)).toBe('0');
expect(fastSerialize(Number.NaN)).toBe('NaN');
});
it('serializes booleans', () => {
expect(fastSerialize(true)).toBe('true');
expect(fastSerialize(false)).toBe('false');
});
it('serializes functions', () => {
const testFunc = (_x: 2) => 3;
expect(fastSerialize(testFunc)).toBe('(_x) => 3');
});
it('serializes arrays', () => {
expect(fastSerialize([])).toBe('[]');
expect(fastSerialize([1, 2, 3])).toBe('[3]');
});
it('serializes plain objects', () => {
expect(fastSerialize({})).toBe('{}');
expect(fastSerialize({ a: 1, b: 2 })).toBe('{2}');
});
it('serializes deeply nested objects with depth limit', () => {
const nested = { a: { b: { c: 1 } } };
expect(fastSerialize(nested, 0)).toBe('{1}');
expect(fastSerialize(nested, -1)).toBe('…');
});
it('serializes objects with custom constructors', () => {
class CustomClass {}
const instance = new CustomClass();
expect(fastSerialize(instance)).toBe('CustomClass{…}');
});
it('serializes unknown objects gracefully', () => {
const date = new Date();
const serialized = fastSerialize(date);
expect(serialized.includes('Date')).toBe(true);
});
});
================================================
FILE: packages/scan/src/core/index.ts
================================================
import { type Signal, signal } from '@preact/signals';
import {
type Fiber,
detectReactBuildType,
getRDTHook,
getType,
isInstrumentationActive,
} from 'bippy';
import type { ComponentType } from 'preact';
import type { ReactNode } from 'preact/compat';
import type { RenderData } from 'src/core/utils';
import { initReactScanInstrumentation } from 'src/new-outlines';
import styles from '~web/assets/css/styles.css';
import { createToolbar } from '~web/toolbar';
import { IS_CLIENT } from '~web/utils/constants';
import { readLocalStorage, saveLocalStorage } from '~web/utils/helpers';
import type { States } from '~web/views/inspector/utils';
import type {
ChangeReason,
Render,
createInstrumentation,
} from './instrumentation';
import { startTimingTracking } from './notifications/event-tracking';
import { createHighlightCanvas } from './notifications/outline-overlay';
import packageJson from '../../package.json';
let rootContainer: HTMLDivElement | null = null;
let shadowRoot: ShadowRoot | null = null;
interface RootContainer {
rootContainer: HTMLDivElement;
shadowRoot: ShadowRoot;
}
const initRootContainer = (): RootContainer => {
if (rootContainer && shadowRoot) {
return { rootContainer, shadowRoot };
}
rootContainer = document.createElement('div');
rootContainer.id = 'react-scan-root';
shadowRoot = rootContainer.attachShadow({ mode: 'open' });
const cssStyles = document.createElement('style');
cssStyles.textContent = styles;
shadowRoot.appendChild(cssStyles);
document.documentElement.appendChild(rootContainer);
return { rootContainer, shadowRoot };
};
export interface Options {
/**
* Enable/disable scanning
*
* Please use the recommended way:
* enabled: process.env.NODE_ENV === 'development',
*
* @default true
*/
enabled?: boolean;
/**
* Force React Scan to run in production (not recommended)
*
* @default false
*/
dangerouslyForceRunInProduction?: boolean;
/**
* Log renders to the console
*
* WARNING: This can add significant overhead when the app re-renders frequently
*
* @default false
*/
log?: boolean;
/**
* Show toolbar bar
*
* If you set this to true, and set {@link enabled} to false, the toolbar will still show, but scanning will be disabled.
*
* @default true
*/
showToolbar?: boolean;
/**
* Animation speed
*
* @default "fast"
*/
animationSpeed?: 'slow' | 'fast' | 'off';
/**
* Track unnecessary renders, and mark their outlines gray when detected
*
* An unnecessary render is defined as the component re-rendering with no change to the component's
* corresponding dom subtree
*
* @default false
* @warning tracking unnecessary renders can add meaningful overhead to react-scan
*/
trackUnnecessaryRenders?: boolean;
/**
* Should the FPS meter show in the toolbar
*
* @default true
*/
showFPS?: boolean;
/**
* Should the number of slowdown notifications be shown in the toolbar
*
* @default true
*/
showNotificationCount?: boolean;
/**
* Allow React Scan to run inside iframes
*
* @default false
*/
allowInIframe?: boolean;
/**
* Should react scan log internal errors to the console.
*
* Useful if react scan is not behaving expected and you want to provide information to maintainers when submitting an issue https://github.com/aidenybai/react-scan/issues
*
* @default false
*/
_debug?: 'verbose' | false;
onCommitStart?: () => void;
onRender?: (fiber: Fiber, renders: Array) => void;
onCommitFinish?: () => void;
}
export interface StoreType {
inspectState: Signal;
wasDetailsOpen: Signal;
lastReportTime: Signal;
isInIframe: Signal;
fiberRoots: WeakSet;
reportData: Map;
legacyReportData: Map;
changesListeners: Map>;
interactionListeningForRenders:
| ((fiber: Fiber, renders: Array) => void)
| null;
}
export type OutlineKey = `${string}-${string}`;
export interface Internals {
instrumentation: ReturnType | null;
componentAllowList: WeakMap, Options> | null;
options: Signal;
onRender: ((fiber: Fiber, renders: Array) => void) | null;
Store: StoreType;
version: string;
runInAllEnvironments: boolean;
}
export type FunctionalComponentStateChange = {
type: ChangeReason.FunctionalState;
value: unknown;
prevValue?: unknown;
count?: number | undefined;
name: string;
};
export type ClassComponentStateChange = {
type: ChangeReason.ClassState;
value: unknown;
prevValue?: unknown;
count?: number | undefined;
name: 'state';
};
export type StateChange =
| FunctionalComponentStateChange
| ClassComponentStateChange;
export type PropsChange = {
type: ChangeReason.Props;
name: string;
value: unknown;
prevValue?: unknown;
count?: number | undefined;
};
export type ContextChange = {
type: ChangeReason.Context;
name: string;
value: unknown;
prevValue?: unknown;
count?: number | undefined;
contextType: number;
};
export type Change = StateChange | PropsChange | ContextChange;
export type ChangesPayload = {
propsChanges: Array;
stateChanges: Array<
FunctionalComponentStateChange | ClassComponentStateChange
>;
contextChanges: Array;
};
export type ChangesListener = (changes: ChangesPayload) => void;
export const Store: StoreType = {
wasDetailsOpen: signal(true),
isInIframe: signal(IS_CLIENT && window.self !== window.top),
inspectState: signal({
kind: 'uninitialized',
}),
fiberRoots: new Set(),
reportData: new Map(),
legacyReportData: new Map(),
lastReportTime: signal(0),
interactionListeningForRenders: null,
changesListeners: new Map(),
};
export const ReactScanInternals: Internals = {
instrumentation: null,
componentAllowList: null,
options: signal({
enabled: true,
log: false,
showToolbar: true,
animationSpeed: 'fast',
dangerouslyForceRunInProduction: false,
showFPS: true,
showNotificationCount: true,
allowInIframe: false,
}),
runInAllEnvironments: false,
onRender: null,
Store,
version: packageJson.version,
};
if (IS_CLIENT && window.__REACT_SCAN_EXTENSION__) {
window.__REACT_SCAN_VERSION__ = ReactScanInternals.version;
}
export type LocalStorageOptions = Omit<
Options,
'onCommitStart' | 'onRender' | 'onCommitFinish'
>;
const applyLocalStorageOptions = (options: Options): LocalStorageOptions => {
const {
onCommitStart,
onRender,
onCommitFinish,
...rest
} = options;
return rest;
};
const validateOptions = (options: Partial): Partial => {
const errors: Array = [];
const validOptions: Partial = {};
for (const key in options) {
const value = options[key as keyof Options];
switch (key) {
case 'enabled':
case 'log':
case 'showToolbar':
case 'showNotificationCount':
case 'dangerouslyForceRunInProduction':
case 'showFPS':
case 'allowInIframe':
if (typeof value !== 'boolean') {
errors.push(`- ${key} must be a boolean. Got "${value}"`);
} else {
validOptions[key] = value;
}
break;
case 'animationSpeed':
if (!['slow', 'fast', 'off'].includes(value as string)) {
errors.push(
`- Invalid animation speed "${value}". Using default "fast"`,
);
} else {
validOptions[key] = value as 'slow' | 'fast' | 'off';
}
break;
case 'onCommitStart':
if (typeof value !== 'function') {
errors.push(`- ${key} must be a function. Got "${value}"`);
} else {
validOptions.onCommitStart = value as () => void;
}
break;
case 'onCommitFinish':
if (typeof value !== 'function') {
errors.push(`- ${key} must be a function. Got "${value}"`);
} else {
validOptions.onCommitFinish = value as () => void;
}
break;
case 'onRender':
if (typeof value !== 'function') {
errors.push(`- ${key} must be a function. Got "${value}"`);
} else {
validOptions.onRender = value as (
fiber: Fiber,
renders: Array,
) => void;
}
break;
default:
errors.push(`- Unknown option "${key}"`);
}
}
if (errors.length > 0) {
// oxlint-disable-next-line no-console
console.warn(`[React Scan] Invalid options:\n${errors.join('\n')}`);
}
return validOptions;
};
export const getReport = (type?: ComponentType) => {
if (type) {
for (const reportData of Array.from(Store.legacyReportData.values())) {
if (reportData.type === type) {
return reportData;
}
}
return null;
}
return Store.legacyReportData;
};
export const setOptions = (userOptions: Partial) => {
try {
const validOptions = validateOptions(userOptions);
if (Object.keys(validOptions).length === 0) {
return;
}
const shouldInitToolbar =
'showToolbar' in validOptions && validOptions.showToolbar !== undefined;
const newOptions = {
...ReactScanInternals.options.value,
...validOptions,
};
const { instrumentation } = ReactScanInternals;
if (instrumentation && 'enabled' in validOptions) {
instrumentation.isPaused.value = validOptions.enabled === false;
}
ReactScanInternals.options.value = newOptions;
// temp hack since defaults override stored local storage values
// we actually don't care about any other local storage option other than enabled, we should not be syncing those to local storage
try {
const existing = readLocalStorage>(
'react-scan-options',
)?.enabled;
if (typeof existing === 'boolean') {
newOptions.enabled = existing;
}
} catch (e) {
if (ReactScanInternals.options.value._debug === 'verbose') {
// oxlint-disable-next-line no-console
console.error(
'[React Scan Internal Error]',
'Failed to create notifications outline canvas',
e,
);
}
/** */
}
saveLocalStorage(
'react-scan-options',
applyLocalStorageOptions(newOptions),
);
if (shouldInitToolbar) {
initToolbar(!!newOptions.showToolbar);
}
return newOptions;
} catch (e) {
if (ReactScanInternals.options.value._debug === 'verbose') {
// oxlint-disable-next-line no-console
console.error(
'[React Scan Internal Error]',
'Failed to create notifications outline canvas',
e,
);
}
/** */
}
};
export const getOptions = () => ReactScanInternals.options;
// we only need to run this check once and will read the value in hot path
let isProduction: boolean | null = null;
let rdtHook: ReturnType;
export const getIsProduction = () => {
if (isProduction !== null) {
return isProduction;
}
rdtHook ??= getRDTHook();
for (const renderer of rdtHook.renderers.values()) {
const buildType = detectReactBuildType(renderer);
if (buildType === 'production') {
isProduction = true;
}
}
return isProduction;
};
export const start = () => {
try {
if (!IS_CLIENT) {
return;
}
if (
!ReactScanInternals.runInAllEnvironments &&
getIsProduction() &&
!ReactScanInternals.options.value.dangerouslyForceRunInProduction
) {
return;
}
const localStorageOptions =
readLocalStorage('react-scan-options');
if (localStorageOptions) {
const validLocalOptions = validateOptions(localStorageOptions);
if (Object.keys(validLocalOptions).length > 0) {
ReactScanInternals.options.value = {
...ReactScanInternals.options.value,
...validLocalOptions,
};
}
}
const options = getOptions();
initReactScanInstrumentation(() => {
initToolbar(!!options.value.showToolbar);
});
if (IS_CLIENT) {
setTimeout(() => {
if (isInstrumentationActive()) return;
// oxlint-disable-next-line no-console
console.error(
'[React Scan] Failed to load. Must import React Scan before React runs.',
);
}, 5000);
}
} catch (e) {
if (ReactScanInternals.options.value._debug === 'verbose') {
// oxlint-disable-next-line no-console
console.error(
'[React Scan Internal Error]',
'Failed to create notifications outline canvas',
e,
);
}
}
};
const initToolbar = (showToolbar: boolean) => {
window.reactScanCleanupListeners?.();
const cleanupTimingTracking = startTimingTracking();
const cleanupOutlineCanvas = createNotificationsOutlineCanvas();
window.reactScanCleanupListeners = () => {
cleanupTimingTracking();
cleanupOutlineCanvas?.();
};
const windowToolbarContainer = window.__REACT_SCAN_TOOLBAR_CONTAINER__;
if (!showToolbar) {
windowToolbarContainer?.remove();
return;
}
windowToolbarContainer?.remove();
const { shadowRoot } = initRootContainer();
createToolbar(shadowRoot);
};
const createNotificationsOutlineCanvas = () => {
try {
const highlightRoot = document.documentElement;
return createHighlightCanvas(highlightRoot);
} catch (e) {
if (ReactScanInternals.options.value._debug === 'verbose') {
// oxlint-disable-next-line no-console
console.error(
'[React Scan Internal Error]',
'Failed to create notifications outline canvas',
e,
);
}
}
};
export const scan = (options: Options = {}) => {
setOptions(options);
const isInIframe = Store.isInIframe.value;
if (
isInIframe &&
!ReactScanInternals.options.value.allowInIframe &&
!ReactScanInternals.runInAllEnvironments
) {
return;
}
if (options.enabled === false && options.showToolbar !== true) {
return;
}
start();
};
export const useScan = (options: Options = {}) => {
setOptions(options);
start();
};
export const onRender = (
type: unknown,
_onRender: (fiber: Fiber, renders: Array) => void,
) => {
const prevOnRender = ReactScanInternals.onRender;
ReactScanInternals.onRender = (fiber, renders) => {
prevOnRender?.(fiber, renders);
if (getType(fiber.type) === type) {
_onRender(fiber, renders);
}
};
};
export const ignoredProps = new WeakSet<
Exclude
>();
export const ignoreScan = (node: ReactNode) => {
if (node && typeof node === 'object') {
ignoredProps.add(node);
}
};
================================================
FILE: packages/scan/src/core/instrumentation.ts
================================================
import { type Signal, signal } from '@preact/signals';
import {
ClassComponentTag,
type Fiber,
type FiberRoot,
ForwardRefTag,
FunctionComponentTag,
MemoComponentTag,
type MemoizedState,
SimpleMemoComponentTag,
didFiberCommit,
getDisplayName,
getFiberId,
getMutatedHostFibers,
getTimings,
getType,
hasMemoCache,
instrument,
traverseContexts,
traverseProps,
traverseRenderedFibers,
} from 'bippy';
import { isValidElement } from 'preact';
import { isEqual } from '~core/utils';
import {
collectContextChanges,
collectPropsChanges,
collectStateChanges,
} from '~web/views/inspector/timeline/utils';
import {
type Change,
type ContextChange,
ReactScanInternals,
type StateChange,
} from './index';
export enum RenderPhase {
Mount = 0b001,
Update = 0b010,
Unmount = 0b100,
}
export const RENDER_PHASE_STRING_TO_ENUM = {
mount: RenderPhase.Mount,
update: RenderPhase.Update,
unmount: RenderPhase.Unmount,
} as const;
export interface AggregatedChange {
type: number;
unstable: boolean;
}
export interface AggregatedRender {
name: string;
frame: number | null;
phase: number;
time: number | null;
aggregatedCount: number;
forget: boolean;
changes: AggregatedChange;
unnecessary: boolean | null;
didCommit: boolean;
fps: number;
computedKey: import('./index').OutlineKey | null;
computedCurrent: DOMRect | null;
}
let fps = 0;
let lastTime = performance.now();
let frameCount = 0;
let initedFps = false;
const updateFPS = () => {
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
fps = frameCount;
frameCount = 0;
lastTime = now;
}
requestAnimationFrame(updateFPS);
};
export const getFPS = () => {
if (!initedFps) {
initedFps = true;
updateFPS();
fps = 60;
}
return fps;
};
export const isElementVisible = (el: Element) => {
const style = window.getComputedStyle(el);
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.contentVisibility !== 'hidden' &&
style.opacity !== '0'
);
};
export const isValueUnstable = (prevValue: unknown, nextValue: unknown) => {
const prevValueString = fastSerialize(prevValue);
const nextValueString = fastSerialize(nextValue);
return (
prevValueString === nextValueString &&
unstableTypes.includes(typeof prevValue) &&
unstableTypes.includes(typeof nextValue)
);
};
export const isElementInViewport = (
el: Element,
rect = el.getBoundingClientRect(),
) => {
const isVisible =
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth;
return isVisible && rect.width && rect.height;
};
export const enum ChangeReason {
Props = 0b001,
FunctionalState = 0b010,
ClassState = 0b011,
Context = 0b100,
}
export interface AggregatedChange {
type: number; // union of AggregatedChangeReason
unstable: boolean;
}
export interface Render {
phase: RenderPhase;
componentName: string | null;
time: number | null;
count: number;
forget: boolean;
changes: Array;
unnecessary: boolean | null;
didCommit: boolean;
fps: number;
}
const unstableTypes = ['function', 'object'];
const cache = new WeakMap();
export function fastSerialize(value: unknown, depth = 0): string {
if (depth < 0) return '…';
switch (typeof value) {
case 'function':
return value.toString();
case 'string':
return value;
case 'number':
case 'boolean':
case 'undefined':
return String(value);
case 'object':
break;
default:
return String(value);
}
if (value === null) return 'null';
if (cache.has(value)) {
const cached = cache.get(value);
if (cached !== undefined) {
return cached;
}
}
if (Array.isArray(value)) {
const str = value.length ? `[${value.length}]` : '[]';
cache.set(value, str);
return str;
}
if (isValidElement(value)) {
const type = getDisplayName(value.type) ?? '';
const propCount = value.props ? Object.keys(value.props).length : 0;
const str = `<${type} ${propCount}>`;
cache.set(value, str);
return str;
}
if (Object.getPrototypeOf(value) === Object.prototype) {
const keys = Object.keys(value);
const str = keys.length ? `{${keys.length}}` : '{}';
cache.set(value, str);
return str;
}
const ctor =
value && typeof value === 'object' ? value.constructor : undefined;
if (ctor && typeof ctor === 'function' && ctor.name) {
const str = `${ctor.name}{…}`;
cache.set(value, str);
return str;
}
const tagString = Object.prototype.toString.call(value).slice(8, -1);
const str = `${tagString}{…}`;
cache.set(value, str);
return str;
}
export const getStateChanges = (fiber: Fiber): StateChange[] => {
if (!fiber) return [];
const changes: StateChange[] = [];
if (
fiber.tag === FunctionComponentTag ||
fiber.tag === ForwardRefTag ||
fiber.tag === SimpleMemoComponentTag ||
fiber.tag === MemoComponentTag
) {
let memoizedState: MemoizedState | null = fiber.memoizedState;
let prevState: MemoizedState | null | undefined =
fiber.alternate?.memoizedState;
let index = 0;
while (memoizedState) {
if (memoizedState.queue && memoizedState.memoizedState !== undefined) {
const change: StateChange = {
type: ChangeReason.FunctionalState,
name: index.toString(),
value: memoizedState.memoizedState,
prevValue: prevState?.memoizedState,
};
if (!isEqual(change.prevValue, change.value)) {
changes.push(change);
}
}
memoizedState = memoizedState.next;
prevState = prevState?.next;
index++;
}
return changes;
}
if (fiber.tag === ClassComponentTag) {
// when we have class component fiber, memoizedState is the component state
const change: StateChange = {
type: ChangeReason.ClassState,
name: 'state',
value: fiber.memoizedState,
prevValue: fiber.alternate?.memoizedState,
};
if (!isEqual(change.prevValue, change.value)) {
changes.push(change);
}
return changes;
}
return changes;
};
interface ContextFiber {
context: unknown; // refers to Context;
memoizedValue: unknown;
}
let lastContextId = 0;
const contextIdMap = new WeakMap();
const getContextId = (contextFiber: ContextFiber) => {
const existing = contextIdMap.get(contextFiber);
if (existing) {
return existing;
}
lastContextId++;
contextIdMap.set(contextFiber, lastContextId);
return lastContextId;
};
function getContextChangesTraversal(
this: Array,
nextValue: ContextFiber | null | undefined,
prevValue: ContextFiber | null | undefined,
): void {
if (!nextValue || !prevValue) return;
// const prevMemoizedValue = prevValue.memoizedValue;
const nextMemoizedValue = nextValue.memoizedValue;
const change: ContextChange = {
type: ChangeReason.Context,
name:
(nextValue.context as { displayName: string | undefined }).displayName ??
'Context.Provider',
value: nextMemoizedValue,
contextType: getContextId(nextValue.context as ContextFiber),
// unstable: false,
};
this.push(change);
// const prevValueString = fastSerialize(prevMemoizedValue);
// const nextValueString = fastSerialize(nextMemoizedValue);
// if (
// unstableTypes.includes(typeof prevMemoizedValue) &&
// unstableTypes.includes(typeof nextMemoizedValue) &&
// prevValueString === nextValueString
// ) {
// change.unstable = true;
// }
}
export const getContextChanges = (fiber: Fiber) => {
const changes: Array = [];
// Alexis: we use bind functions so that the compiler doesn't produce
// any closures
traverseContexts(fiber, getContextChangesTraversal.bind(changes));
return changes;
};
type OnRenderHandler = (fiber: Fiber, renders: Array) => void;
type OnCommitStartHandler = () => void;
type OnCommitFinishHandler = () => void;
type OnErrorHandler = (error: unknown) => void;
type IsValidFiberHandler = (fiber: Fiber) => boolean;
type OnActiveHandler = () => void;
interface InstrumentationConfig {
onCommitStart: OnCommitStartHandler;
isValidFiber: IsValidFiberHandler;
onRender: OnRenderHandler;
onCommitFinish: OnCommitFinishHandler;
onError: OnErrorHandler;
onActive?: OnActiveHandler;
onPostCommitFiberRoot: () => void;
// monitoring does not need to track changes, and it adds overhead to leave it on
trackChanges: boolean;
// allows monitoring to continue tracking renders even if react scan dev mode is disabled
forceAlwaysTrackRenders?: boolean;
}
interface InstrumentationInstance {
key: string;
config: InstrumentationConfig;
instrumentation: Instrumentation;
}
interface Instrumentation {
isPaused: Signal;
fiberRoots: WeakSet;
}
const instrumentationInstances = new Map();
let inited = false;
const getAllInstances = () => Array.from(instrumentationInstances.values());
interface IsRenderUnnecessaryState {
isRequiredChange: boolean;
}
function isRenderUnnecessaryTraversal(
this: IsRenderUnnecessaryState,
_propsName: string,
prevValue: unknown,
nextValue: unknown,
): void {
if (
!isEqual(prevValue, nextValue) &&
!isValueUnstable(prevValue, nextValue)
) {
this.isRequiredChange = true;
}
}
// FIXME: calculation is slow
export const isRenderUnnecessary = (fiber: Fiber) => {
if (!didFiberCommit(fiber)) return true;
const mutatedHostFibers = getMutatedHostFibers(fiber);
for (const mutatedHostFiber of mutatedHostFibers) {
const state: IsRenderUnnecessaryState = {
isRequiredChange: false,
};
traverseProps(mutatedHostFiber, isRenderUnnecessaryTraversal.bind(state));
if (state.isRequiredChange) return false;
}
return true;
};
// // re-implement this in new-outlines
// const shouldRunUnnecessaryRenderCheck = () => {
// // yes, this can be condensed into one conditional, but ifs are easier to reason/build on than long boolean expressions
// if (!ReactScanInternals.options.value.trackUnnecessaryRenders) {
// return false;
// }
// // only run unnecessaryRenderCheck when monitoring is active in production if the user set dangerouslyForceRunInProduction
// if (
// getIsProduction() &&
// Store.monitor.value &&
// ReactScanInternals.options.value.dangerouslyForceRunInProduction &&
// ReactScanInternals.options.value.trackUnnecessaryRenders
// ) {
// return true;
// }
// if (getIsProduction() && Store.monitor.value) {
// return false;
// }
// return ReactScanInternals.options.value.trackUnnecessaryRenders;
// };
const TRACK_UNNECESSARY_RENDERS = false;
export interface RenderData {
selfTime: number;
totalTime: number;
renderCount: number;
lastRenderTimestamp: number;
}
export interface OldRenderData {
count: number;
time: number;
renders: Array;
displayName: string | null;
// oxlint-disable-next-line typescript/no-explicit-any
type: any;
// oxlint-disable-next-line typescript/no-explicit-any
changes?: any;
}
const RENDER_DEBOUNCE_MS = 16;
export const renderDataMap = new WeakMap>();
function getFiberIdentifier(fiber: Fiber) {
return String(getFiberId(fiber));
}
export function getRenderData(fiber: Fiber) {
const id = getFiberIdentifier(fiber);
const keyMap = renderDataMap.get(getType(fiber) as object);
if (keyMap) {
return keyMap.get(id);
}
return undefined;
}
export function setRenderData(fiber: Fiber, value: RenderData) {
const type = getType(fiber.type);
const id = getFiberIdentifier(fiber);
let keyMap = renderDataMap.get(type as object);
if (!keyMap) {
keyMap = new Map();
renderDataMap.set(type as object, keyMap);
}
keyMap.set(id, value);
}
const trackRender = (
fiber: Fiber,
fiberSelfTime: number,
fiberTotalTime: number,
hasChanges: boolean,
hasDomMutations: boolean,
) => {
const currentTimestamp = Date.now();
const existingData = getRenderData(fiber);
if (
(hasChanges || hasDomMutations) &&
(!existingData ||
currentTimestamp - (existingData.lastRenderTimestamp || 0) >
RENDER_DEBOUNCE_MS)
) {
const renderData: RenderData = existingData || {
selfTime: 0,
totalTime: 0,
renderCount: 0,
lastRenderTimestamp: currentTimestamp,
};
renderData.renderCount = (renderData.renderCount || 0) + 1;
renderData.selfTime = fiberSelfTime || 0;
renderData.totalTime = fiberTotalTime || 0;
renderData.lastRenderTimestamp = currentTimestamp;
setRenderData(fiber, { ...renderData });
}
};
export const createInstrumentation = (
instanceKey: string,
config: InstrumentationConfig,
) => {
const instrumentation: Instrumentation = {
// this will typically be false, but in cases where a user provides showToolbar: true, this will be true
isPaused: signal(!ReactScanInternals.options.value.enabled),
fiberRoots: new WeakSet(),
};
instrumentationInstances.set(instanceKey, {
key: instanceKey,
config,
instrumentation,
});
if (!inited) {
inited = true;
instrument({
name: 'react-scan',
onActive: config.onActive,
onCommitFiberRoot(_rendererID, root) {
instrumentation.fiberRoots.add(root);
// for now we always track everything for notifications, it may be worth it to make this configurable
// if (
// ReactScanInternals.instrumentation?.isPaused.value &&
// (Store.inspectState.value.kind === "inspect-off" ||
// Store.inspectState.value.kind === "uninitialized") &&
// !config.forceAlwaysTrackRenders
// ) {
// return;
// }
const allInstances = getAllInstances();
for (const instance of allInstances) {
instance.config.onCommitStart();
}
traverseRenderedFibers(
root.current,
(fiber: Fiber, phase: 'mount' | 'update' | 'unmount') => {
const type = getType(fiber.type);
if (!type) return null;
const allInstances = getAllInstances();
const validInstancesIndicies: Array = [];
for (let i = 0, len = allInstances.length; i < len; i++) {
const instance = allInstances[i];
if (!instance.config.isValidFiber(fiber)) continue;
validInstancesIndicies.push(i);
}
if (!validInstancesIndicies.length) return null;
const changes: Array = [];
if (allInstances.some((instance) => instance.config.trackChanges)) {
const changesProps = collectPropsChanges(fiber).changes;
const changesState = collectStateChanges(fiber).changes;
const changesContext = collectContextChanges(fiber).changes;
changes.push.apply(
null,
changesProps.map(
(change) =>
({
type: ChangeReason.Props,
name: change.name,
value: change.value,
}) as Change,
),
);
for (const change of changesState) {
if (fiber.tag === ClassComponentTag) {
changes.push({
type: ChangeReason.ClassState,
name: change.name.toString(),
value: change.value,
} as Change);
} else {
changes.push({
type: ChangeReason.FunctionalState,
name: change.name.toString(),
value: change.value,
} as Change);
}
}
changes.push.apply(
null,
changesContext.map(
(change) =>
({
type: ChangeReason.Context,
name: change.name,
value: change.value,
contextType: Number(change.contextType),
}) as Change,
),
);
}
const { selfTime: fiberSelfTime, totalTime: fiberTotalTime } =
getTimings(fiber);
const fps = getFPS();
const render: Render = {
phase: RENDER_PHASE_STRING_TO_ENUM[phase],
componentName: getDisplayName(type),
count: 1,
changes,
time: fiberSelfTime,
forget: hasMemoCache(fiber),
// todo: allow this to be toggle-able through toolbar
// todo: performance optimization: if the last fiber measure was very off screen, do not run isRenderUnnecessary
unnecessary: TRACK_UNNECESSARY_RENDERS
? isRenderUnnecessary(fiber)
: null,
didCommit: didFiberCommit(fiber),
fps,
};
// First, determine if this is a real render we should track
const hasChanges = changes.length > 0;
const hasDomMutations = getMutatedHostFibers(fiber).length > 0;
if (phase === 'update') {
trackRender(
fiber,
fiberSelfTime,
fiberTotalTime,
hasChanges,
hasDomMutations,
);
}
for (let i = 0, len = validInstancesIndicies.length; i < len; i++) {
const index = validInstancesIndicies[i];
const instance = allInstances[index];
instance.config.onRender(fiber, [render]);
}
},
);
for (const instance of allInstances) {
instance.config.onCommitFinish();
}
},
onPostCommitFiberRoot() {
const allInstances = getAllInstances();
for (const instance of allInstances) {
instance.config.onPostCommitFiberRoot();
}
},
});
}
return instrumentation;
};
================================================
FILE: packages/scan/src/core/notifications/event-tracking.ts
================================================
import { useSyncExternalStore } from 'preact/compat';
import { not_globally_unique_generateId } from '~core/utils';
import { MAX_INTERACTION_BATCH, interactionStore } from './interaction-store';
import {
FiberRenders,
PerformanceEntryChannelEvent,
TimeoutStage,
listenForPerformanceEntryInteractions,
listenForRenders,
setupDetailedPointerTimingListener,
setupPerformancePublisher,
} from './performance';
import {
MAX_CHANNEL_SIZE,
performanceEntryChannels,
} from './performance-store';
import { BoundedArray } from './performance-utils';
import { createStore } from '~web/utils/create-store';
let profileListeners: Array<(interaction: FinalInteraction) => void> = [];
type FinalInteraction = {
detailedTiming: TimeoutStage;
latency: number;
completedAt: number;
};
export const listenForProfile = (
listener: (interaction: FinalInteraction) => void,
) => {
profileListeners.push(listener);
return () => {
profileListeners = profileListeners.filter(
(existingListener) => existingListener !== listener,
);
};
};
export let interactionStatus:
| { kind: 'started'; startedAt: number }
| { kind: 'completed'; startedAt: number; endedAt: number }
| { kind: 'no-interaction' } = {
kind: 'no-interaction',
};
type NewInteractionStoreState = {
/**
* problem definition: we need to store bounds but how do we handle uninitialized bounds
*
* i guess what we said before, we just have one active bounds and that's all that matters chat
*/
startAt: number;
endAt: number;
};
export const interactionStatusStore: {
state: NewInteractionStoreState | null;
listeners: Array<(state: NewInteractionStoreState) => void>;
addListener: (cb: (state: NewInteractionStoreState) => void) => () => void;
} = {
state: null,
addListener: (cb) => {
interactionStatusStore.listeners.push(cb);
return () => {
interactionStatusStore.listeners =
interactionStatusStore.listeners.filter((l) => l !== cb);
};
},
listeners: [],
};
let accumulatedFiberRendersOverTask: null | FiberRenders = null;
type InteractionEvent = {
kind: 'interaction';
data: {
startAt: number;
endAt: number;
meta: {
detailedTiming: TimeoutStage;
latency: number;
kind: PerformanceEntryChannelEvent['kind'];
};
};
};
type LongRenderPipeline = {
kind: 'long-render';
data: {
startAt: number;
endAt: number;
meta: {
latency: number;
fiberRenders: FiberRenders;
fps: number;
};
};
};
export type SlowdownEvent = (InteractionEvent | LongRenderPipeline) & {
id: string;
};
type ToolbarEventStoreState = {
state: {
events: BoundedArray;
};
actions: {
addEvent: (event: SlowdownEvent) => void;
addListener: (listener: (event: SlowdownEvent) => void) => () => void;
clear: () => void;
};
};
type DebugEvent = {
kind: string;
at: number;
meta?: unknown;
};
export const debugEventStore = createStore<{
state: {
events: Array;
};
actions: {
// oxlint-disable-next-line typescript/no-explicit-any
addEvent: (event: any) => void;
clear: () => void;
};
}>()((set) => ({
state: {
events: [],
},
actions: {
addEvent: (event: DebugEvent) => {
set((store) => ({
state: {
events: [...store.state.events, event],
},
}));
},
clear: () => {
set({
state: {
events: [],
},
});
},
},
}));
const EVENT_STORE_CAPACITY = 200;
export const toolbarEventStore = createStore()(
(set, get) => {
const listeners = new Set<(event: SlowdownEvent) => void>();
return {
state: {
events: new BoundedArray(EVENT_STORE_CAPACITY),
},
actions: {
addEvent: (event: SlowdownEvent) => {
listeners.forEach((listener) => listener(event));
const events = [...get().state.events, event];
const applyOverlapCheckToLongRenderEvent = (
longRenderEvent: LongRenderPipeline & { id: string },
onOverlap: (
overlapsWith: InteractionEvent & { id: string },
) => void,
) => {
const overlapsWith = events.find((event) => {
if (event.kind === 'long-render') {
return;
}
if (event.id === longRenderEvent.id) {
return;
}
/**
* |---x-----------x------ (interaction)
* |x-----------x (long-render)
*/
if (
longRenderEvent.data.startAt <= event.data.startAt &&
longRenderEvent.data.endAt <= event.data.endAt &&
longRenderEvent.data.endAt >= event.data.startAt
) {
return true;
}
/**
* |x-----------x---- (interaction)
* |--x------------x (long-render)
*
*/
if (
event.data.startAt <= longRenderEvent.data.startAt &&
event.data.endAt >= longRenderEvent.data.startAt
) {
return true;
}
/**
*
* |--x-------------x (interaction)
* |x------------------x (long-render)
*
*/
if (
longRenderEvent.data.startAt <= event.data.startAt &&
longRenderEvent.data.endAt >= event.data.endAt
) {
return true;
}
}) as undefined | (InteractionEvent & { id: string }); // invariant: because we early check the typechecker does not know it must be the case that when it finds something, it will be an interaction it overlaps with
if (overlapsWith) {
onOverlap(overlapsWith);
}
};
const toRemove = new Set();
events.forEach((event) => {
if (event.kind === 'interaction') return;
applyOverlapCheckToLongRenderEvent(event, () => {
toRemove.add(event.id);
});
});
const withRemovedEvents = events.filter(
(event) => !toRemove.has(event.id),
);
set(() => ({
state: {
events: BoundedArray.fromArray(
withRemovedEvents,
EVENT_STORE_CAPACITY,
),
},
}));
},
addListener: (listener: (event: SlowdownEvent) => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
clear: () => {
set({
state: {
events: new BoundedArray(EVENT_STORE_CAPACITY),
},
});
},
},
};
},
);
export const useToolbarEventLog = () => {
return useSyncExternalStore(
toolbarEventStore.subscribe,
toolbarEventStore.getState,
);
};
let taskDirtyAt: null | number = null;
let taskDirtyOrigin: null | number = null;
let previousTrackCurrentMouseOverElementCallback:
| ((e: MouseEvent) => void)
| null = null;
let overToolbar: boolean | null;
const trackCurrentMouseOverToolbar = () => {
const callback = (e: MouseEvent) => {
overToolbar = e
.composedPath()
.map((path) => (path as Element).id)
.filter(Boolean)
.includes('react-scan-toolbar');
};
document.addEventListener('mouseover', callback);
previousTrackCurrentMouseOverElementCallback = callback;
return () => {
if (previousTrackCurrentMouseOverElementCallback) {
document.removeEventListener(
'mouseover',
previousTrackCurrentMouseOverElementCallback,
);
}
};
};
// stops long tasks b/c backgrounded from being reported
export const startDirtyTaskTracking = () => {
const onVisibilityChange = () => {
taskDirtyAt = performance.now();
taskDirtyOrigin = performance.timeOrigin;
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
};
export const HIGH_SEVERITY_FPS_DROP_TIME = 150;
let framesDrawnInTheLastSecond: Array = [];
export function startLongPipelineTracking() {
let rafHandle: number;
let timeoutHandle: ReturnType;
function measure() {
let unSub: (() => void) | null = null;
accumulatedFiberRendersOverTask = null;
accumulatedFiberRendersOverTask = {};
unSub = listenForRenders(accumulatedFiberRendersOverTask);
const startOrigin = performance.timeOrigin;
const startTime = performance.now();
rafHandle = requestAnimationFrame(() => {
// very low overhead, on the order of dozens of microseconds to run
timeoutHandle = setTimeout(() => {
const endNow = performance.now();
const duration = endNow - startTime;
const endOrigin = performance.timeOrigin;
framesDrawnInTheLastSecond.push(endNow + endOrigin);
const framesInTheLastSecond = framesDrawnInTheLastSecond.filter(
(frameAt) => endNow + endOrigin - frameAt <= 1000,
);
const fps = framesInTheLastSecond.length;
framesDrawnInTheLastSecond = framesInTheLastSecond;
const taskConsideredDirty =
taskDirtyAt !== null && taskDirtyOrigin !== null
? endNow + endOrigin - (taskDirtyOrigin + taskDirtyAt) < 100
: null;
// not useful to report slowdowns caused by things like outlines (can get expensive not fully optimized)
const wasTaskInfluencedByToolbar = overToolbar !== null && overToolbar;
if (
duration > HIGH_SEVERITY_FPS_DROP_TIME &&
!taskConsideredDirty &&
document.visibilityState === 'visible' &&
!wasTaskInfluencedByToolbar
) {
const endAt = endOrigin + endNow;
const startAt = startTime + startOrigin;
toolbarEventStore.getState().actions.addEvent({
kind: 'long-render',
id: not_globally_unique_generateId(),
data: {
endAt: endAt,
startAt: startAt,
meta: {
// oxlint-disable-next-line typescript/no-non-null-assertion
fiberRenders: accumulatedFiberRendersOverTask!,
latency: duration,
fps,
},
},
});
}
taskDirtyAt = null;
taskDirtyOrigin = null;
unSub?.();
measure();
}, 0);
});
return unSub;
}
const measureUnSub = measure();
return () => {
measureUnSub();
cancelAnimationFrame(rafHandle);
clearTimeout(timeoutHandle);
};
}
export const startTimingTracking = () => {
const unSubPerformance = setupPerformancePublisher();
const unSubMouseOver = trackCurrentMouseOverToolbar();
const unSubDirtyTaskTracking = startDirtyTaskTracking();
const unSubLongPipelineTracking = startLongPipelineTracking();
const onComplete = async (
_: string,
finalInteraction: FinalInteraction,
event: PerformanceEntryChannelEvent,
) => {
toolbarEventStore.getState().actions.addEvent({
kind: 'interaction',
id: not_globally_unique_generateId(),
data: {
startAt: finalInteraction.detailedTiming.blockingTimeStart,
endAt: performance.now() + performance.timeOrigin,
meta: { ...finalInteraction, kind: event.kind }, // TODO, will need interaction specific metadata here
},
});
const existingCompletedInteractions =
performanceEntryChannels.getChannelState('recording');
finalInteraction.detailedTiming.stopListeningForRenders();
if (existingCompletedInteractions.length) {
// then performance entry and our detailed timing handlers are out of sync, we disregard that entry
// it may be possible the performance entry returned before detailed timing. If that's the case we should update
// assumptions and deal with mapping the entry back to the detailed timing here
performanceEntryChannels.updateChannelState(
'recording',
() => new BoundedArray(MAX_CHANNEL_SIZE),
);
}
};
const unSubDetailedPointerTiming = setupDetailedPointerTimingListener(
'pointer',
{
onComplete,
},
);
const unSubDetailedKeyboardTiming = setupDetailedPointerTimingListener(
'keyboard',
{
onComplete,
},
);
const unSubInteractions = listenForPerformanceEntryInteractions(
(completedInteraction) => {
interactionStore.setState(
BoundedArray.fromArray(
interactionStore.getCurrentState().concat(completedInteraction),
MAX_INTERACTION_BATCH,
),
);
},
);
return () => {
unSubMouseOver();
unSubDirtyTaskTracking();
unSubLongPipelineTracking();
unSubPerformance();
unSubDetailedPointerTiming();
unSubInteractions();
unSubDetailedKeyboardTiming();
};
};
================================================
FILE: packages/scan/src/core/notifications/interaction-store.ts
================================================
import { BoundedArray } from "~core/notifications/performance-utils";
import { CompletedInteraction } from "./performance";
type Subscriber = (data: T) => void;
export class Store {
private subscribers: Set> = new Set();
private currentValue: T;
constructor(initialValue: T) {
this.currentValue = initialValue;
}
subscribe(subscriber: Subscriber): () => void {
this.subscribers.add(subscriber);
subscriber(this.currentValue);
return () => {
this.subscribers.delete(subscriber);
};
}
setState(data: T) {
this.currentValue = data;
this.subscribers.forEach((subscriber) => subscriber(data));
}
getCurrentState(): T {
return this.currentValue;
}
}
export const MAX_INTERACTION_BATCH = 150;
export const interactionStore = new Store>(
new BoundedArray(MAX_INTERACTION_BATCH)
);
================================================
FILE: packages/scan/src/core/notifications/outline-overlay.ts
================================================
import { signal } from '@preact/signals';
import { iife } from './performance-utils';
export let highlightCanvas: HTMLCanvasElement | null = null;
export let highlightCtx: CanvasRenderingContext2D | null = null;
let animationFrame: number | null = null;
type TransitionHighlightState = {
kind: 'transition';
transitionTo: {
name: string;
rects: Array;
alpha: number;
};
current: {
name: string;
rects: Array;
alpha: number;
} | null;
};
type HighlightState =
| TransitionHighlightState
| {
kind: 'move-out';
current: {
name: string;
rects: Array;
alpha: number;
};
}
| {
kind: 'idle';
current: {
name: string;
rects: Array;
} | null;
};
export const HighlightStore = signal({
kind: 'idle',
current: null,
});
let currFrame: ReturnType | null = null;
let lastFrameTime = 0;
const FADE_SPEED = 1.8;
const MAX_DELTA = 0.05;
const DEFAULT_DELTA = 1 / 60;
export const drawHighlights = () => {
if (currFrame) {
cancelAnimationFrame(currFrame);
}
currFrame = requestAnimationFrame((timestamp) => {
if (!highlightCanvas || !highlightCtx) {
return;
}
const dt = lastFrameTime
? Math.min((timestamp - lastFrameTime) / 1000, MAX_DELTA)
: DEFAULT_DELTA;
lastFrameTime = timestamp;
const step = FADE_SPEED * dt;
highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height);
const color = 'hsl(271, 76%, 53%)';
const state = HighlightStore.value;
const { alpha, current } = iife(() => {
switch (state.kind) {
case 'transition': {
const current =
state.current?.alpha && state.current.alpha > 0
? state.current
: state.transitionTo;
return {
alpha: current ? current.alpha : 0,
current,
};
}
case 'move-out': {
return { alpha: state.current?.alpha ?? 0, current: state.current };
}
case 'idle': {
return { alpha: 1, current: state.current };
}
}
// exhaustive check
state satisfies never;
});
current?.rects.forEach((rect) => {
if (!highlightCtx) {
// typescript cant tell this closure is synchronous/non-escaping
return;
}
highlightCtx.shadowColor = color;
highlightCtx.shadowBlur = 6;
highlightCtx.strokeStyle = color;
highlightCtx.lineWidth = 2;
highlightCtx.globalAlpha = alpha;
highlightCtx.beginPath();
highlightCtx.rect(rect.left, rect.top, rect.width, rect.height);
highlightCtx.stroke();
highlightCtx.shadowBlur = 0;
highlightCtx.beginPath();
highlightCtx.rect(rect.left, rect.top, rect.width, rect.height);
highlightCtx.stroke();
});
switch (state.kind) {
case 'move-out': {
if (state.current.alpha === 0) {
HighlightStore.value = {
kind: 'idle',
current: null,
};
lastFrameTime = 0;
return;
}
if (state.current.alpha <= 0.01) {
state.current.alpha = 0;
}
state.current.alpha = Math.max(0, state.current.alpha - step);
drawHighlights();
return;
}
case 'transition': {
if (state.current && state.current.alpha > 0) {
state.current.alpha = Math.max(0, state.current.alpha - step);
drawHighlights();
return;
}
// invariant, state.current.alpha === 0
if (state.transitionTo.alpha === 1) {
HighlightStore.value = {
kind: 'idle',
current: state.transitionTo,
};
lastFrameTime = 0;
return;
}
state.transitionTo.alpha = Math.min(state.transitionTo.alpha + step, 1);
drawHighlights();
}
case 'idle': {
// no-op
lastFrameTime = 0;
return;
}
}
});
};
let handleResizeListener: (() => void) | null = null;
export const createHighlightCanvas = (root: HTMLElement) => {
highlightCanvas = document.createElement('canvas');
highlightCtx = highlightCanvas.getContext('2d', { alpha: true });
if (!highlightCtx) return null;
const dpr = window.devicePixelRatio || 1;
const { innerWidth, innerHeight } = window;
highlightCanvas.style.width = `${innerWidth}px`;
highlightCanvas.style.height = `${innerHeight}px`;
highlightCanvas.width = innerWidth * dpr;
highlightCanvas.height = innerHeight * dpr;
highlightCanvas.style.position = 'fixed';
highlightCanvas.style.left = '0';
highlightCanvas.style.top = '0';
highlightCanvas.style.pointerEvents = 'none';
highlightCanvas.style.zIndex = '2147483600';
highlightCtx.scale(dpr, dpr);
root.appendChild(highlightCanvas);
if (handleResizeListener) {
window.removeEventListener('resize', handleResizeListener);
}
const handleResize = () => {
if (!highlightCanvas || !highlightCtx) return;
const dpr = window.devicePixelRatio || 1;
const { innerWidth, innerHeight } = window;
highlightCanvas.style.width = `${innerWidth}px`;
highlightCanvas.style.height = `${innerHeight}px`;
highlightCanvas.width = innerWidth * dpr;
highlightCanvas.height = innerHeight * dpr;
highlightCtx.scale(dpr, dpr);
drawHighlights();
};
handleResizeListener = handleResize;
window.addEventListener('resize', handleResize);
HighlightStore.subscribe(() => {
requestAnimationFrame(() => {
drawHighlights();
});
});
return cleanup;
};
export function cleanup() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
if (highlightCanvas?.parentNode) {
highlightCanvas.parentNode.removeChild(highlightCanvas);
}
highlightCanvas = null;
highlightCtx = null;
}
================================================
FILE: packages/scan/src/core/notifications/performance-store.ts
================================================
import { BoundedArray } from "./performance-utils";
import { PerformanceEntryChannelEvent } from "./performance";
type UnSubscribe = () => void;
type Callback = (item: T) => void;
type Updater = (state: BoundedArray) => BoundedArray;
type ChanelName = string;
type PerformanceEntryChannelsType = {
subscribe: (to: ChanelName, cb: Callback) => UnSubscribe;
publish: (
item: T,
to: ChanelName,
dropFirst: boolean,
createIfNoChannel: boolean
) => void;
channels: Record<
ChanelName,
{ callbacks: BoundedArray>; state: BoundedArray }
>;
getAvailableChannels: () => BoundedArray;
updateChannelState: (
channel: ChanelName,
updater: Updater,
createIfNoChannel: boolean
) => void;
};
export const MAX_CHANNEL_SIZE = 50;
// a set of entities communicate to each other through channels
// the state in the channel is persisted until the receiving end consumes it
// multiple subscribes to the same channel will likely lead to unintended behavior if the subscribers are separate entities
class PerformanceEntryChannels implements PerformanceEntryChannelsType {
channels: PerformanceEntryChannelsType["channels"] = {};
publish(item: T, to: ChanelName, createIfNoChannel = true) {
const existingChannel = this.channels[to];
if (!existingChannel) {
if (!createIfNoChannel) {
return;
}
this.channels[to] = {
callbacks: new BoundedArray>(MAX_CHANNEL_SIZE),
state: new BoundedArray(MAX_CHANNEL_SIZE),
};
this.channels[to].state.push(item);
return;
}
existingChannel.state.push(item);
existingChannel.callbacks.forEach((cb) => cb(item));
}
getAvailableChannels() {
return BoundedArray.fromArray(Object.keys(this.channels), MAX_CHANNEL_SIZE);
}
subscribe(to: ChanelName, cb: Callback, dropFirst: boolean = false) {
const defer = () => {
if (!dropFirst) {
this.channels[to].state.forEach((item) => {
cb(item);
});
}
return () => {
const filtered = this.channels[to].callbacks.filter(
(subscribed) => subscribed !== cb
);
this.channels[to].callbacks = BoundedArray.fromArray(
filtered,
MAX_CHANNEL_SIZE
);
};
};
const existing = this.channels[to];
if (!existing) {
this.channels[to] = {
callbacks: new BoundedArray>(MAX_CHANNEL_SIZE),
state: new BoundedArray(MAX_CHANNEL_SIZE),
};
this.channels[to].callbacks.push(cb);
return defer();
}
existing.callbacks.push(cb);
return defer();
}
updateChannelState(
channel: ChanelName,
updater: Updater,
createIfNoChannel = true
) {
const existingChannel = this.channels[channel];
if (!existingChannel) {
if (!createIfNoChannel) {
return;
}
const state = new BoundedArray(MAX_CHANNEL_SIZE);
const newChannel = {
callbacks: new BoundedArray>(MAX_CHANNEL_SIZE),
state,
};
this.channels[channel] = newChannel;
newChannel.state = updater(state);
return;
}
existingChannel.state = updater(existingChannel.state);
}
getChannelState(channel: ChanelName) {
return (
this.channels[channel].state ?? new BoundedArray(MAX_CHANNEL_SIZE)
);
}
}
// todo: discriminated union the events when we start using multiple channels
// we used to use multiple channels, but now we only use 1. This is still a useful abstraction incase we ever need more channels again
export const performanceEntryChannels =
new PerformanceEntryChannels();
================================================
FILE: packages/scan/src/core/notifications/performance-utils.ts
================================================
import { Fiber } from 'bippy';
export const getChildrenFromFiberLL = (fiber: Fiber) => {
const children: Array = [];
let curr: typeof fiber.child = fiber.child;
while (curr) {
children.push(curr);
curr = curr.sibling;
}
return children;
};
type Node = Map<
Fiber,
{
children: Array;
parent: Fiber | null;
isRoot: boolean;
isSVG: boolean;
}
>;
export const createChildrenAdjacencyList = (root: Fiber, limit: number) => {
const tree: Node = new Map([]);
const queue: Array<[node: Fiber, parent: Fiber | null]> = [];
const visited = new Set();
queue.push([root, root.return]);
let traversed = 1;
while (queue.length) {
if (traversed >= limit) {
return tree;
}
// oxlint-disable-next-line typescript/no-non-null-assertion
const [node, parent] = queue.pop()!;
const children = getChildrenFromFiberLL(node);
tree.set(node, {
children: [],
parent,
isRoot: node === root,
isSVG: node.type === 'svg',
});
for (const child of children) {
traversed += 1;
// this isn't needed since the fiber tree is a TREE, not a graph, but it makes me feel safer
if (visited.has(child)) {
continue;
}
visited.add(child);
tree.get(node)?.children.push(child);
queue.push([child, node]);
}
}
return tree;
};
const THROW_INVARIANTS = false;
export const invariantError = (message: string | undefined) => {
if (THROW_INVARIANTS) {
throw new Error(message);
}
};
export const iife = (fn: () => T): T => fn();
export class BoundedArray extends Array {
constructor(private capacity: number = 25) {
super();
}
push(...items: T[]): number {
const result = super.push(...items);
while (this.length > this.capacity) {
this.shift();
}
return result;
}
// do not couple capacity with a default param, it must be explicit
static fromArray(array: Array, capacity: number) {
const arr = new BoundedArray(capacity);
arr.push(...array);
return arr;
}
}
================================================
FILE: packages/scan/src/core/notifications/performance.ts
================================================
import {
Fiber,
getDisplayName,
getTimings,
hasMemoCache,
isHostFiber,
traverseFiber,
} from 'bippy';
import { Store } from '../..';
import {
BoundedArray,
invariantError,
} from '~core/notifications/performance-utils';
import {
SectionData,
collectInspectorDataWithoutCounts,
} from '~web/views/inspector/timeline/utils';
import {
getFiberFromElement,
getParentCompositeFiber,
} from '~web/views/inspector/utils';
import { performanceEntryChannels } from './performance-store';
import type {
PerformanceInteraction,
PerformanceInteractionEntry,
} from './types';
import { not_globally_unique_generateId } from '~core/utils';
interface PathFilters {
skipProviders: boolean;
skipHocs: boolean;
skipContainers: boolean;
skipMinified: boolean;
skipUtilities: boolean;
skipBoundaries: boolean;
}
const DEFAULT_PATH_FILTERS: PathFilters = {
skipProviders: true,
skipHocs: true,
skipContainers: true,
skipMinified: true,
skipUtilities: true,
skipBoundaries: true,
};
const PATH_FILTER_PATTERNS = {
providers: [/Provider$/, /^Provider$/, /^Context$/],
hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/],
containers: [/^(?:App)?Container$/, /^Root$/, /^ReactDev/],
utilities: [
/^Fragment$/,
/^Suspense$/,
/^ErrorBoundary$/,
/^Portal$/,
/^Consumer$/,
/^Layout$/,
/^Router/,
/^Hydration/,
],
boundaries: [/^Boundary$/, /Boundary$/, /^Provider$/, /Provider$/],
};
const shouldIncludeInPath = (
name: string,
filters: PathFilters = DEFAULT_PATH_FILTERS,
): boolean => {
const patternsToCheck: Array = [];
if (filters.skipProviders) patternsToCheck.push(...PATH_FILTER_PATTERNS.providers);
if (filters.skipHocs) patternsToCheck.push(...PATH_FILTER_PATTERNS.hocs);
if (filters.skipContainers) patternsToCheck.push(...PATH_FILTER_PATTERNS.containers);
if (filters.skipUtilities) patternsToCheck.push(...PATH_FILTER_PATTERNS.utilities);
if (filters.skipBoundaries) patternsToCheck.push(...PATH_FILTER_PATTERNS.boundaries);
return !patternsToCheck.some((pattern) => pattern.test(name));
};
const minifiedPatterns = [
/^[a-z]$/,
/^[a-z][0-9]$/,
/^_+$/,
/^[A-Za-z][_$]$/,
/^[a-z]{1,2}$/,
];
const isMinified = (name: string): boolean => {
for (let i = 0; i < minifiedPatterns.length; i++) {
if (minifiedPatterns[i].test(name)) return true;
}
const hasNoVowels = !/[aeiou]/i.test(name);
const hasMostlyNumbers = (name.match(/\d/g)?.length ?? 0) > name.length / 2;
const isSingleWordLowerCase = /^[a-z]+$/.test(name);
const hasRandomLookingChars = /[$_]{2,}/.test(name);
return (
Number(hasNoVowels) +
Number(hasMostlyNumbers) +
Number(isSingleWordLowerCase) +
Number(hasRandomLookingChars) >=
2
);
};
interface FiberType {
displayName?: string;
name?: string;
[key: string]: unknown;
}
const getCleanComponentName = (component: FiberType): string => {
const name = getDisplayName(component);
if (!name) return '';
return name.replace(
/^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/,
'$',
);
};
const getInteractionPath = (
initialFiber: Fiber | null,
filters: PathFilters = DEFAULT_PATH_FILTERS,
): Array => {
if (!initialFiber) return [];
const currentName = getDisplayName(initialFiber.type);
if (!currentName) return [];
const stack = new Array();
let fiber = initialFiber;
while (fiber.return) {
const name = getCleanComponentName(fiber.type);
if (name && !isMinified(name) && shouldIncludeInPath(name, filters) && name.toLowerCase() !== name) {
stack.push(name);
}
fiber = fiber.return;
}
const fullPath = new Array(stack.length);
for (let i = 0; i < stack.length; i++) {
fullPath[i] = stack[stack.length - i - 1];
}
return fullPath;
};
const getFirstNameFromAncestor = (
fiber: Fiber,
accept: (name: string) => boolean = () => true,
) => {
let curr: Fiber | null = fiber;
while (curr) {
const currName = getDisplayName(curr.type);
if (currName && accept(currName)) {
return currName;
}
curr = curr.return;
}
return null;
};
let unsubscribeTrackVisibilityChange: (() => void) | undefined;
// fixme: compress me if this stays here for bad interaction time checks
let lastVisibilityHiddenAt: number | 'never-hidden' = 'never-hidden';
const trackVisibilityChange = () => {
unsubscribeTrackVisibilityChange?.();
const onVisibilityChange = () => {
if (document.hidden) {
lastVisibilityHiddenAt = Date.now();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
unsubscribeTrackVisibilityChange = () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
};
export type FiberRenders = Record<
string,
{
renderCount: number;
parents: Set;
selfTime: number;
totalTime: number;
hasMemoCache: boolean;
wasFiberRenderMount: boolean;
nodeInfo: Array<{
selfTime: number;
element: Element;
name: string;
}>;
changes: ReturnType;
}
>;
/**
* we need to fix:
* - if there's a tab switch during a task being tracked, then u disregard that task (i hope this doesn't make tab switches hard to debug that cause slowdowns, ug i suppose it probably would, right? Depends how the browser queues it but i suppose u can think of a scenario. It would be most optimal to subtract the timing but not sure how reliable that would be)
* - we need to see why the tracking is just off
* - we need to correctly implement the precise activation this time
*/
type InteractionStartStage = {
kind: 'interaction-start';
interactionType: 'pointer' | 'keyboard';
interactionUUID: string;
interactionStartDetail: number;
blockingTimeStart: number;
componentPath: Array;
componentName: string;
childrenTree: Record<
string,
{ children: Array; firstNamedAncestor: string; isRoot: boolean }
>;
fiberRenders: FiberRenders;
stopListeningForRenders: () => void;
};
type JSEndStage = Omit & {
kind: 'js-end-stage';
jsEndDetail: number;
};
type RAFStage = Omit & {
kind: 'raf-stage';
rafStart: number;
};
export type TimeoutStage = Omit & {
kind: 'timeout-stage';
commitEnd: number;
blockingTimeEnd: number;
};
export type PerformanceEntryChannelEvent =
| {
kind: 'entry-received';
entry: PerformanceInteraction;
}
| {
kind: 'auto-complete-race';
interactionUUID: string;
detailedTiming: TimeoutStage;
};
export type CompletedInteraction = {
detailedTiming: TimeoutStage;
latency: number;
completedAt: number;
flushNeeded: boolean;
};
type UnInitializedStage = {
kind: 'uninitialized-stage';
// todo: no longer a uuid
interactionUUID: string;
interactionType: 'pointer' | 'keyboard';
};
type CurrentInteraction = {
kind: 'pointer' | 'keyboard';
interactionUUID: string;
pointerUpStart: number;
// needed for when inputs that can be clicked and trigger on change (like checkboxes)
clickChangeStart: number | null;
clickHandlerMicroTaskEnd: number | null;
rafStart: number | null;
commmitEnd: number | null;
timeorigin: number;
// for now i don't trust performance now timing for UTC time...
blockingTimeStart: number;
blockingTimeEnd: number | null;
fiberRenders: Map<
string,
{
renderCount: number;
parents: Set;
selfTime: number;
}
>;
componentPath: Array;
componentName: string;
childrenTree: Record<
string,
{ children: Array; firstNamedAncestor: string; isRoot: boolean }
>;
};
export let currentInteractions: Array = [];
const getInteractionType = (
eventName: string,
): 'pointer' | 'keyboard' | null => {
// todo: track pointer down, but tends to not house expensive logic so not very high priority
if (['pointerup', 'click'].includes(eventName)) {
return 'pointer';
}
if (eventName.includes('key')) {
}
if (['keydown', 'keyup'].includes(eventName)) {
return 'keyboard';
}
return null;
};
let onEntryAnimationId: number | null = null;
const setupPerformanceListener = (
onEntry: (interaction: PerformanceInteraction) => void,
) => {
trackVisibilityChange();
const interactionMap = new Map();
const interactionTargetMap = new Map();
const processInteractionEntry = (entry: PerformanceInteractionEntry) => {
if (!entry.interactionId) return;
if (
entry.interactionId &&
entry.target &&
!interactionTargetMap.has(entry.interactionId)
) {
interactionTargetMap.set(entry.interactionId, entry.target);
}
if (entry.target) {
let current: Element | null = entry.target;
while (current) {
if (
current.id === 'react-scan-toolbar-root' ||
current.id === 'react-scan-root'
) {
return;
}
current = current.parentElement;
}
}
const existingInteraction = interactionMap.get(entry.interactionId);
if (existingInteraction) {
if (entry.duration > existingInteraction.latency) {
existingInteraction.entries = [entry];
existingInteraction.latency = entry.duration;
} else if (
entry.duration === existingInteraction.latency &&
entry.startTime === existingInteraction.entries[0].startTime
) {
existingInteraction.entries.push(entry);
}
} else {
const interactionType = getInteractionType(entry.name);
if (!interactionType) {
return;
}
const interaction: PerformanceInteraction = {
id: entry.interactionId,
latency: entry.duration,
entries: [entry],
target: entry.target,
type: interactionType,
startTime: entry.startTime,
endTime: Date.now(),
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
duration: entry.duration,
inputDelay: entry.processingStart - entry.startTime,
processingDuration: entry.processingEnd - entry.processingStart,
presentationDelay:
entry.duration - (entry.processingEnd - entry.startTime),
// componentPath:
timestamp: Date.now(),
timeSinceTabInactive:
lastVisibilityHiddenAt === 'never-hidden'
? 'never-hidden'
: Date.now() - lastVisibilityHiddenAt,
visibilityState: document.visibilityState,
timeOrigin: performance.timeOrigin,
referrer: document.referrer,
};
//
interactionMap.set(interaction.id, interaction);
/**
* This seems odd, but it gives us determinism that we will receive an entry AFTER our detailed timing collection
* runs because browser semantics (raf(() => setTimeout) will always run before a doubleRaf)
*
* this also handles the case where multiple entries are dispatched for semantically the same interaction,
* they will get merged into a single interaction, where the largest latency is recorded, which is what
* we are interested in this application
*/
if (!onEntryAnimationId) {
onEntryAnimationId = requestAnimationFrame(() => {
requestAnimationFrame(() => {
// oxlint-disable-next-line typescript/no-non-null-assertion
onEntry(interactionMap.get(interaction.id)!);
onEntryAnimationId = null;
});
});
}
}
};
const po = new PerformanceObserver((list) => {
const entries = list.getEntries();
for (let i = 0, len = entries.length; i < len; i++) {
const entry = entries[i];
processInteractionEntry(entry as PerformanceInteractionEntry);
}
});
try {
po.observe({
type: 'event',
buffered: true,
durationThreshold: 16,
} as PerformanceObserverInit);
po.observe({
type: 'first-input',
buffered: true,
});
} catch {
/* Should collect error logs*/
}
return () => po.disconnect();
};
export const setupPerformancePublisher = () => {
return setupPerformanceListener((entry) => {
performanceEntryChannels.publish(
{
kind: 'entry-received',
entry,
},
'recording',
);
});
};
// we should actually only feed it the information it needs to complete so we can support safari
type Task = {
completeInteraction: (
entry: PerformanceEntryChannelEvent,
) => CompletedInteraction;
startDateTime: number;
endDateTime: number;
type: 'keyboard' | 'pointer';
interactionUUID: string;
};
export const MAX_INTERACTION_TASKS = 25;
let tasks = new BoundedArray(MAX_INTERACTION_TASKS);
const getAssociatedDetailedTimingInteraction = (
entry: PerformanceInteraction,
activeTasks: Array,
) => {
let closestTask: Task | null = null;
for (const task of activeTasks) {
if (task.type !== entry.type) {
continue;
}
if (closestTask === null) {
closestTask = task;
continue;
}
const getAbsoluteDiff = (task: Task, entry: PerformanceInteraction) =>
Math.abs(task.startDateTime) - (entry.startTime + entry.timeOrigin);
if (getAbsoluteDiff(task, entry) < getAbsoluteDiff(closestTask, entry)) {
closestTask = task;
}
}
return closestTask;
};
// this would be cool if it listened for merge, so it had to be after
export const listenForPerformanceEntryInteractions = (
onComplete: (completedInteraction: CompletedInteraction) => void,
) => {
// we make the assumption that the detailed timing will be ready before the performance timing
const unsubscribe = performanceEntryChannels.subscribe(
'recording',
(event) => {
const associatedDetailedInteraction =
event.kind === 'auto-complete-race'
? tasks.find((task) => task.interactionUUID === event.interactionUUID)
: getAssociatedDetailedTimingInteraction(event.entry, tasks);
// REMINDME: this likely means we clicked a non interactable thing but our handler still ran
// so we shouldn't treat this as an invariant, but instead use it to verify if we clicked
// something interactable
if (!associatedDetailedInteraction) {
return;
}
const completedInteraction =
associatedDetailedInteraction.completeInteraction(event);
onComplete(completedInteraction);
},
);
return unsubscribe;
};
type ShouldContinue = boolean;
const trackDetailedTiming = ({
onMicroTask,
onRAF,
onTimeout,
abort,
}: {
onMicroTask: () => ShouldContinue;
onRAF: () => ShouldContinue;
onTimeout: () => void;
abort?: () => boolean;
}) => {
queueMicrotask(() => {
if (abort?.() === true) {
return;
}
if (!onMicroTask()) {
return;
}
requestAnimationFrame(() => {
if (abort?.() === true) {
return;
}
if (!onRAF()) {
return;
}
setTimeout(() => {
if (abort?.() === true) {
return;
}
onTimeout();
}, 0);
});
});
};
const getTargetInteractionDetails = (target: Element) => {
const associatedFiber = getFiberFromElement(target);
if (!associatedFiber) {
return;
}
// TODO: if element is minified, squash upwards till first non minified ancestor, and set name as ChildOf()
let componentName = associatedFiber
? getDisplayName(associatedFiber?.type)
: 'N/A';
if (!componentName) {
componentName =
getFirstNameFromAncestor(associatedFiber, (name) => name.length > 2) ??
'N/A';
}
if (!componentName) {
return;
}
const componentPath = getInteractionPath(associatedFiber);
return {
componentPath,
childrenTree: {},
componentName,
elementFiber: associatedFiber,
};
};
type LastInteractionRef = {
current: (
| InteractionStartStage
| JSEndStage
| RAFStage
| TimeoutStage
| UnInitializedStage
) & { stageStart: number };
};
/**
*
* handles tracking event timings for arbitrarily overlapping handlers with cancel logic
*/
export const setupDetailedPointerTimingListener = (
kind: 'pointer' | 'keyboard',
options: {
onStart?: (interactionUUID: string) => void;
onComplete?: (
interactionUUID: string,
finalInteraction: {
detailedTiming: TimeoutStage;
latency: number;
completedAt: number;
flushNeeded: boolean;
},
entry: PerformanceEntryChannelEvent,
) => void;
onError?: (interactionUUID: string) => void;
},
) => {
let instrumentationIdInControl: string | null = null;
const getEvent = (
info: { phase: 'start' } | { phase: 'end'; target: Element },
) => {
switch (kind) {
case 'pointer': {
if (info.phase === 'start') {
return 'pointerup';
}
if (
info.target instanceof HTMLInputElement ||
info.target instanceof HTMLSelectElement
) {
return 'change';
}
return 'click';
}
case 'keyboard': {
if (info.phase === 'start') {
return 'keydown';
}
return 'change';
}
}
};
const lastInteractionRef: LastInteractionRef = {
current: {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(), // the first interaction uses this
stageStart: Date.now(),
interactionType: kind,
},
};
const onInteractionStart = (e: Event) => {
const path = e.composedPath();
if (
path.some(
(el) => el instanceof Element && el.id === 'react-scan-toolbar-root',
)
) {
return;
}
if (Date.now() - lastInteractionRef.current.stageStart > 2000) {
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: Date.now(),
interactionType: kind,
};
}
if (lastInteractionRef.current.kind !== 'uninitialized-stage') {
return;
}
const pointerUpStart = performance.now();
options?.onStart?.(lastInteractionRef.current.interactionUUID);
const details = getTargetInteractionDetails(e.target as HTMLElement);
if (!details) {
options?.onError?.(lastInteractionRef.current.interactionUUID);
return;
}
const fiberRenders: InteractionStartStage['fiberRenders'] = {};
const stopListeningForRenders = listenForRenders(fiberRenders);
lastInteractionRef.current = {
...lastInteractionRef.current,
interactionType: kind,
blockingTimeStart: Date.now(),
childrenTree: details.childrenTree,
componentName: details.componentName,
componentPath: details.componentPath,
fiberRenders,
kind: 'interaction-start',
interactionStartDetail: pointerUpStart,
stopListeningForRenders,
};
const event = getEvent({ phase: 'end', target: e.target as Element });
// oxlint-disable-next-line typescript/no-explicit-any
document.addEventListener(event, onLastJS as any, {
once: true,
});
// this is an edge case where a click event is not fired after a pointerdown
// im not sure why this happens, but it seems to only happen on non intractable elements
// it causes the event handler to stay alive until a future interaction, which can break timing (looks super long)
// or invariants (the start metadata was removed, so now its an end metadata with no start)
requestAnimationFrame(() => {
// oxlint-disable-next-line typescript/no-explicit-any
document.removeEventListener(event as any, onLastJS as any);
});
};
document.addEventListener(
getEvent({ phase: 'start' }),
// oxlint-disable-next-line typescript/no-explicit-any
onInteractionStart as any,
{
capture: true,
},
);
/**
*
* TODO: IF WE DETECT RENDERS DURING THIS PERIOD WE CAN INCLUDE THAT IN THE RESULT AND THEN BACK THAT OUT OF COMPUTED STYLE TIME AND ADD IT BACK INTO JS TIME
*/
const onLastJS = (
e: { target: Element },
instrumentationId: string,
abort: () => boolean,
) => {
if (
lastInteractionRef.current.kind !== 'interaction-start' &&
instrumentationId === instrumentationIdInControl
) {
if (kind === 'pointer' && e.target instanceof HTMLSelectElement) {
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: Date.now(),
interactionType: kind,
};
return;
}
options?.onError?.(lastInteractionRef.current.interactionUUID);
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: Date.now(),
interactionType: kind,
};
invariantError('pointer -> click');
return;
}
instrumentationIdInControl = instrumentationId;
trackDetailedTiming({
abort,
onMicroTask: () => {
if (lastInteractionRef.current.kind === 'uninitialized-stage') {
return false;
}
lastInteractionRef.current = {
...lastInteractionRef.current,
kind: 'js-end-stage',
jsEndDetail: performance.now(),
};
return true;
},
onRAF: () => {
if (
lastInteractionRef.current.kind !== 'js-end-stage' &&
lastInteractionRef.current.kind !== 'raf-stage'
) {
options?.onError?.(lastInteractionRef.current.interactionUUID);
invariantError('bad transition to raf');
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: Date.now(),
interactionType: kind,
};
return false;
}
lastInteractionRef.current = {
...lastInteractionRef.current,
kind: 'raf-stage',
rafStart: performance.now(),
};
return true;
},
onTimeout: () => {
if (lastInteractionRef.current.kind !== 'raf-stage') {
options?.onError?.(lastInteractionRef.current.interactionUUID);
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: Date.now(),
interactionType: kind,
};
invariantError('raf->timeout');
return;
}
const now = Date.now();
const timeoutStage: TimeoutStage = Object.freeze({
...lastInteractionRef.current,
kind: 'timeout-stage',
blockingTimeEnd: now,
commitEnd: performance.now(),
});
lastInteractionRef.current = {
kind: 'uninitialized-stage',
interactionUUID: not_globally_unique_generateId(),
stageStart: now,
interactionType: kind,
};
let completed = false;
const completeInteraction = (event: PerformanceEntryChannelEvent) => {
completed = true;
const latency =
event.kind === 'auto-complete-race'
? event.detailedTiming.commitEnd -
event.detailedTiming.interactionStartDetail
: event.entry.latency;
const finalInteraction = {
detailedTiming: timeoutStage,
latency,
completedAt: Date.now(),
flushNeeded: true,
};
options?.onComplete?.(
timeoutStage.interactionUUID,
finalInteraction,
event,
);
const newTasks = tasks.filter(
(task) => task.interactionUUID !== timeoutStage.interactionUUID,
);
tasks = BoundedArray.fromArray(newTasks, MAX_INTERACTION_TASKS);
return finalInteraction;
};
const task = {
completeInteraction,
endDateTime: Date.now(),
startDateTime: timeoutStage.blockingTimeStart,
type: kind,
interactionUUID: timeoutStage.interactionUUID,
};
tasks.push(task);
if (!isPerformanceEventAvailable()) {
const newTasks = tasks.filter(
(task) => task.interactionUUID !== timeoutStage.interactionUUID,
);
tasks = BoundedArray.fromArray(newTasks, MAX_INTERACTION_TASKS);
completeInteraction({
kind: 'auto-complete-race',
// redundant
detailedTiming: timeoutStage,
interactionUUID: timeoutStage.interactionUUID,
});
} else {
setTimeout(() => {
if (completed) {
return;
}
completeInteraction({
kind: 'auto-complete-race',
// redundant
detailedTiming: timeoutStage,
interactionUUID: timeoutStage.interactionUUID,
});
const newTasks = tasks.filter(
(task) => task.interactionUUID !== timeoutStage.interactionUUID,
);
tasks = BoundedArray.fromArray(newTasks, MAX_INTERACTION_TASKS);
// this means the max frame presentation delta we can observe is 300ms, but this should catch >99% of cases, the trade off is to not accidentally miss slowdowns if the user quickly clicked something else while this race was happening
}, 1000);
}
},
});
};
const onKeyPress = (e: { target: Element }) => {
const id = not_globally_unique_generateId();
onLastJS(e, id, () => id !== instrumentationIdInControl);
};
if (kind === 'keyboard') {
// oxlint-disable-next-line typescript/no-explicit-any
document.addEventListener('keypress', onKeyPress as any);
}
return () => {
document.removeEventListener(
getEvent({ phase: 'start' }),
// oxlint-disable-next-line typescript/no-explicit-any
onInteractionStart as any,
{
capture: true,
},
);
// oxlint-disable-next-line typescript/no-explicit-any
document.removeEventListener('keypress', onKeyPress as any);
};
};
const getHostFromFiber = (fiber: Fiber) => {
return traverseFiber(fiber, (node) => {
// shouldn't be too slow
if (isHostFiber(node)) {
return true;
}
})?.stateNode;
};
const isPerformanceEventAvailable = () => {
return 'PerformanceEventTiming' in globalThis;
};
export const listenForRenders = (
fiberRenders: InteractionStartStage['fiberRenders'],
) => {
const listener = (fiber: Fiber) => {
const displayName = getDisplayName(fiber.type);
if (!displayName) {
return;
}
const existing = fiberRenders[displayName];
if (!existing) {
const parents = new Set();
const res = fiber.return && getParentCompositeFiber(fiber.return);
const parentCompositeName = res && getDisplayName(res[0]);
if (parentCompositeName) {
parents.add(parentCompositeName);
}
const { selfTime, totalTime } = getTimings(fiber);
const newChanges = collectInspectorDataWithoutCounts(fiber);
const emptySection: SectionData = {
current: [],
changes: new Set(),
changesCounts: new Map(),
};
const changes = {
fiberProps: newChanges.fiberProps || emptySection,
fiberState: newChanges.fiberState || emptySection,
fiberContext: newChanges.fiberContext || emptySection,
};
fiberRenders[displayName] = {
renderCount: 1,
hasMemoCache: hasMemoCache(fiber),
wasFiberRenderMount: wasFiberRenderMount(fiber),
parents: parents,
selfTime,
totalTime,
nodeInfo: [
{
element: getHostFromFiber(fiber),
name: getDisplayName(fiber.type) ?? 'Unknown',
selfTime: getTimings(fiber).selfTime,
},
],
changes,
};
return;
}
const parentType = getParentCompositeFiber(fiber)?.[0]?.type;
if (parentType) {
const res = fiber.return && getParentCompositeFiber(fiber.return);
const parentCompositeName = res && getDisplayName(res[0]);
if (parentCompositeName) {
existing.parents.add(parentCompositeName);
}
}
const { selfTime, totalTime } = getTimings(fiber);
const newChanges = collectInspectorDataWithoutCounts(fiber);
if (!newChanges) return;
const emptySection: SectionData = {
current: [],
changes: new Set(),
changesCounts: new Map(),
};
existing.wasFiberRenderMount =
existing.wasFiberRenderMount || wasFiberRenderMount(fiber);
existing.hasMemoCache = existing.hasMemoCache || hasMemoCache(fiber);
existing.changes = {
fiberProps: mergeSectionData(
existing.changes?.fiberProps || emptySection,
newChanges.fiberProps || emptySection,
),
fiberState: mergeSectionData(
existing.changes?.fiberState || emptySection,
newChanges.fiberState || emptySection,
),
fiberContext: mergeSectionData(
existing.changes?.fiberContext || emptySection,
newChanges.fiberContext || emptySection,
),
};
existing.renderCount += 1;
existing.selfTime += selfTime;
existing.totalTime += totalTime;
existing.nodeInfo.push({
element: getHostFromFiber(fiber),
name: getDisplayName(fiber.type) ?? 'Unknown',
selfTime: getTimings(fiber).selfTime,
});
};
Store.interactionListeningForRenders = listener;
return () => {
if (Store.interactionListeningForRenders === listener) {
Store.interactionListeningForRenders = null;
}
};
};
const mergeSectionData = (
existing: SectionData,
newData: SectionData,
): SectionData => {
const mergedSection: SectionData = {
current: [...existing.current],
changes: new Set(),
changesCounts: new Map(),
};
for (const value of newData.current) {
if (!mergedSection.current.some((item) => item.name === value.name)) {
mergedSection.current.push(value);
}
}
for (const change of newData.changes) {
if (typeof change === 'string' || typeof change === 'number') {
mergedSection.changes.add(change);
const existingCount = existing.changesCounts.get(change) || 0;
const newCount = newData.changesCounts.get(change) || 0;
mergedSection.changesCounts.set(change, existingCount + newCount);
}
}
return mergedSection;
};
const wasFiberRenderMount = (fiber: Fiber) => {
if (!fiber.alternate) {
return true;
}
const prevFiber = fiber.alternate;
const wasMounted =
prevFiber &&
prevFiber.memoizedState != null &&
prevFiber.memoizedState.element != null &&
prevFiber.memoizedState.isDehydrated !== true;
const isMounted =
fiber.memoizedState != null &&
fiber.memoizedState.element != null &&
fiber.memoizedState.isDehydrated !== true;
return !wasMounted && isMounted;
};
================================================
FILE: packages/scan/src/core/notifications/types.ts
================================================
export interface PerformanceInteractionEntry extends PerformanceEntry {
interactionId: string;
target: Element;
name: string;
duration: number;
startTime: number;
processingStart: number;
processingEnd: number;
entryType: string;
}
export interface PerformanceInteraction {
id: string;
latency: number;
entries: Array;
target: Element | null;
type: "pointer" | "keyboard";
startTime: number;
endTime: number;
processingStart: number;
processingEnd: number;
duration: number;
inputDelay: number;
processingDuration: number;
presentationDelay: number;
timestamp: number;
timeSinceTabInactive: number | "never-hidden";
visibilityState: DocumentVisibilityState;
timeOrigin: number;
referrer: string;
detailedTiming?: {
jsHandlersTime: number; // pointerup -> click
prePaintTime: number; // click -> RAF
paintTime: number; // RAF -> setTimeout
compositorTime: number; // remaining duration
};
}
================================================
FILE: packages/scan/src/core/utils.ts
================================================
// @ts-nocheck
import { type Fiber, getType } from 'bippy';
import { ReactScanInternals } from '~core/index';
import type { AggregatedChange, AggregatedRender, Render } from './instrumentation';
import { IS_CLIENT } from '~web/utils/constants';
export const aggregateChanges = (
changes: Array,
prevAggregatedChange?: AggregatedChange,
) => {
const newChange = {
type: prevAggregatedChange?.type ?? 0,
unstable: prevAggregatedChange?.unstable ?? false,
};
for (const change of changes) {
newChange.type |= change.type;
newChange.unstable = newChange.unstable || (change.unstable ?? false);
}
return newChange;
};
export const aggregateRender = (
newRender: Render,
prevAggregated: AggregatedRender,
) => {
prevAggregated.changes = aggregateChanges(
newRender.changes,
prevAggregated.changes,
);
prevAggregated.aggregatedCount += 1;
prevAggregated.didCommit = prevAggregated.didCommit || newRender.didCommit;
prevAggregated.forget = prevAggregated.forget || newRender.forget;
prevAggregated.fps = prevAggregated.fps + newRender.fps;
prevAggregated.phase |= newRender.phase;
prevAggregated.time = (prevAggregated.time ?? 0) + (newRender.time ?? 0);
prevAggregated.unnecessary =
prevAggregated.unnecessary || newRender.unnecessary;
};
function descending(a: number, b: number): number {
return b - a;
}
interface ComponentData {
name: string;
forget: boolean;
time: number;
}
function getComponentGroupNames(group: ComponentData[]): string {
let result = group[0].name;
const len = group.length;
const max = Math.min(4, len);
for (let i = 1; i < max; i++) {
result += `, ${group[i].name}`;
}
return result;
}
function getComponentGroupTotalTime(group: ComponentData[]): number {
let result = group[0].time;
for (let i = 1, len = group.length; i < len; i++) {
result += group[i].time;
}
return result;
}
function componentGroupHasForget(group: ComponentData[]): boolean {
for (let i = 0, len = group.length; i < len; i++) {
if (group[i].forget) {
return true;
}
}
return false;
}
export const getLabelText = (
groupedAggregatedRenders: Array,
) => {
let labelText = '';
const componentsByCount = new Map<
number,
Array<{ name: string; forget: boolean; time: number }>
>();
for (const aggregatedRender of groupedAggregatedRenders) {
const { forget, time, aggregatedCount, name } = aggregatedRender;
if (!componentsByCount.has(aggregatedCount)) {
componentsByCount.set(aggregatedCount, []);
}
const components = componentsByCount.get(aggregatedCount);
if (components) {
components.push({ name, forget, time: time ?? 0 });
}
}
const sortedCounts = Array.from(componentsByCount.keys()).sort(descending);
const parts: Array = [];
let cumulativeTime = 0;
for (const count of sortedCounts) {
const componentGroup = componentsByCount.get(count);
if (!componentGroup) continue;
let text = getComponentGroupNames(componentGroup);
const totalTime = getComponentGroupTotalTime(componentGroup);
const hasForget = componentGroupHasForget(componentGroup);
cumulativeTime += totalTime;
if (componentGroup.length > 4) {
text += '…';
}
if (count > 1) {
text += ` × ${count}`;
}
if (hasForget) {
text = `✨${text}`;
}
parts.push(text);
}
labelText = parts.join(', ');
if (!labelText.length) return null;
if (labelText.length > 40) {
labelText = `${labelText.slice(0, 40)}…`;
}
if (cumulativeTime >= 0.01) {
labelText += ` (${Number(cumulativeTime.toFixed(2))}ms)`;
}
return labelText;
};
export const updateFiberRenderData = (fiber: Fiber, renders: Array) => {
ReactScanInternals.options.value.onRender?.(fiber, renders);
const type = getType(fiber.type) || fiber.type;
if (type && (typeof type === 'function' || typeof type === 'object')) {
const renderData = (type.renderData || {
count: 0,
time: 0,
renders: [],
}) as RenderData;
const firstRender = renders[0];
renderData.count += firstRender.count;
renderData.time += firstRender.time ?? 0;
renderData.renders.push(firstRender);
type.renderData = renderData;
}
};
export interface RenderData {
count: number;
time: number;
renders: Array;
displayName: string | null;
type: unknown;
changes?: Array;
}
export function isEqual(a: unknown, b: unknown): boolean {
return a === b || (a !== a && b !== b);
}
export const not_globally_unique_generateId = () => {
if (!IS_CLIENT) {
return '0';
}
// @ts-expect-error
if (window.reactScanIdCounter === undefined) {
// @ts-expect-error
window.reactScanIdCounter = 0;
}
// @ts-expect-error
return `${++window.reactScanIdCounter}`;
};
export const playNotificationSound = (audioContext: AudioContext) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
const options = {
type: 'sine' as OscillatorType,
freq: [
392,
// 523.25,
600,
// 659.25
],
duration: 0.3,
gain: 0.12,
};
const frequencies = options.freq;
const timePerNote = options.duration / frequencies.length;
frequencies.forEach((freq, i) => {
oscillator.frequency.setValueAtTime(
freq,
audioContext.currentTime + i * timePerNote,
);
});
oscillator.type = options.type;
gainNode.gain.setValueAtTime(options.gain, audioContext.currentTime);
gainNode.gain.setTargetAtTime(
0,
audioContext.currentTime + options.duration * 0.7,
0.05,
);
oscillator.start();
oscillator.stop(audioContext.currentTime + options.duration);
};
================================================
FILE: packages/scan/src/index.ts
================================================
import './polyfills';
// Bippy has a side-effect that installs the hook.
import 'bippy';
export * from './core/index';
================================================
FILE: packages/scan/src/install-hook.ts
================================================
export { getRDTHook as init } from 'bippy';
================================================
FILE: packages/scan/src/monitoring/next.ts
================================================
================================================
FILE: packages/scan/src/new-outlines/canvas.ts
================================================
import type { ActiveOutline, OutlineData } from './types';
export const OUTLINE_ARRAY_SIZE = 7;
const MONO_FONT =
'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace';
const INTERPOLATION_SPEED = 0.2;
const SNAP_THRESHOLD = 0.5;
const lerp = (start: number, end: number) => {
const delta = end - start;
if (Math.abs(delta) < SNAP_THRESHOLD) return end;
return start + delta * INTERPOLATION_SPEED;
};
const MAX_PARTS_LENGTH = 4;
const MAX_LABEL_LENGTH = 40;
const TOTAL_FRAMES = 45;
const PRIMARY_COLOR = '115,97,230';
function sortEntry(prev: [number, string[]], next: [number, string[]]): number {
return next[0] - prev[0];
}
function getSortedEntries(
countByNames: Map,
): [number, string[]][] {
const entries = [...countByNames.entries()];
return entries.sort(sortEntry);
}
function getLabelTextPart([count, names]: [number, string[]]): string {
let part = `${names.slice(0, MAX_PARTS_LENGTH).join(', ')} ×${count}`;
if (part.length > MAX_LABEL_LENGTH) {
part = `${part.slice(0, MAX_LABEL_LENGTH)}…`;
}
return part;
}
export const getLabelText = (outlines: ActiveOutline[]): string => {
const nameByCount = new Map();
for (const { name, count } of outlines) {
nameByCount.set(name, (nameByCount.get(name) || 0) + count);
}
const countByNames = new Map();
for (const [name, count] of nameByCount) {
const names = countByNames.get(count);
if (names) {
names.push(name);
} else {
countByNames.set(count, [name]);
}
}
// TODO(Alexis): Optimize
const partsEntries = getSortedEntries(countByNames);
let labelText = getLabelTextPart(partsEntries[0]);
for (let i = 1, len = partsEntries.length; i < len; i++) {
labelText += ', ' + getLabelTextPart(partsEntries[i]);
}
if (labelText.length > MAX_LABEL_LENGTH) {
return `${labelText.slice(0, MAX_LABEL_LENGTH)}…`;
}
return labelText;
};
export const getAreaFromOutlines = (outlines: ActiveOutline[]) => {
let area = 0;
for (const outline of outlines) {
area += outline.width * outline.height;
}
return area;
};
export const updateOutlines = (
activeOutlines: Map,
outlines: OutlineData[],
) => {
for (const { id, name, count, x, y, width, height, didCommit } of outlines) {
const outline: ActiveOutline = {
id,
name,
count,
x,
y,
width,
height,
frame: 0,
targetX: x,
targetY: y,
targetWidth: width,
targetHeight: height,
didCommit,
};
const key = String(outline.id);
const existingOutline = activeOutlines.get(key);
if (existingOutline) {
existingOutline.count++;
existingOutline.frame = 0;
existingOutline.targetX = x;
existingOutline.targetY = y;
existingOutline.targetWidth = width;
existingOutline.targetHeight = height;
existingOutline.didCommit = didCommit;
} else {
activeOutlines.set(key, outline);
}
}
};
export const updateScroll = (
activeOutlines: Map,
deltaX: number,
deltaY: number,
) => {
for (const outline of activeOutlines.values()) {
const newX = outline.x - deltaX;
const newY = outline.y - deltaY;
outline.targetX = newX;
outline.targetY = newY;
}
};
export const initCanvas = (
canvas: HTMLCanvasElement | OffscreenCanvas,
dpr: number,
) => {
const ctx = canvas.getContext('2d', { alpha: true }) as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
if (ctx) {
ctx.scale(dpr, dpr);
}
return ctx;
};
export const drawCanvas = (
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
canvas: HTMLCanvasElement | OffscreenCanvas,
dpr: number,
activeOutlines: Map,
) => {
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
const groupedOutlinesMap = new Map();
const rectMap = new Map<
string,
{
x: number;
y: number;
width: number;
height: number;
alpha: number;
}
>();
for (const outline of activeOutlines.values()) {
const {
x,
y,
width,
height,
targetX,
targetY,
targetWidth,
targetHeight,
frame,
} = outline;
if (targetX !== x) {
outline.x = lerp(x, targetX);
}
if (targetY !== y) {
outline.y = lerp(y, targetY);
}
if (targetWidth !== width) {
outline.width = lerp(width, targetWidth);
}
if (targetHeight !== height) {
outline.height = lerp(height, targetHeight);
}
const labelKey = `${targetX ?? x},${targetY ?? y}`;
const rectKey = `${labelKey},${targetWidth ?? width},${targetHeight ?? height}`;
const outlines = groupedOutlinesMap.get(labelKey);
if (outlines) {
outlines.push(outline);
} else {
groupedOutlinesMap.set(labelKey, [outline]);
}
const alpha = 1 - frame / TOTAL_FRAMES;
outline.frame++;
const rect = rectMap.get(rectKey) || {
x,
y,
width,
height,
alpha,
};
if (alpha > rect.alpha) {
rect.alpha = alpha;
}
rectMap.set(rectKey, rect);
}
for (const { x, y, width, height, alpha } of rectMap.values()) {
ctx.strokeStyle = `rgba(${PRIMARY_COLOR},${alpha})`;
ctx.lineWidth = 1;
// Offset by 0.5px for crisp 1px strokes on pixel boundaries
const rx = Math.round(x) + 0.5;
const ry = Math.round(y) + 0.5;
const rw = Math.round(width);
const rh = Math.round(height);
ctx.beginPath();
ctx.rect(rx, ry, rw, rh);
ctx.stroke();
ctx.fillStyle = `rgba(${PRIMARY_COLOR},${alpha * 0.1})`;
ctx.fill();
}
ctx.font = `11px ${MONO_FONT}`;
const labelMap = new Map<
string,
{
text: string;
width: number;
height: number;
alpha: number;
x: number;
y: number;
outlines: ActiveOutline[];
}
>();
ctx.textRendering = 'optimizeSpeed';
// TODO(Alexis): optimizable?
for (const outlines of groupedOutlinesMap.values()) {
const first = outlines[0];
const { x, y, frame } = first;
const alpha = 1 - frame / TOTAL_FRAMES;
const text = getLabelText(outlines);
const { width } = ctx.measureText(text);
const height = 11;
labelMap.set(`${x},${y},${width},${text}`, {
text,
width,
height,
alpha,
x,
y,
outlines,
});
let labelY: number = y - height - 4;
if (labelY < 0) {
labelY = 0;
}
if (frame > TOTAL_FRAMES) {
for (const outline of outlines) {
activeOutlines.delete(String(outline.id));
}
}
}
// TODO(Alexis): optimize
const sortedLabels = Array.from(labelMap.entries()).sort(
([_, a], [__, b]) => {
return getAreaFromOutlines(b.outlines) - getAreaFromOutlines(a.outlines);
},
);
for (const [labelKey, label] of sortedLabels) {
if (!labelMap.has(labelKey)) continue;
for (const [otherKey, otherLabel] of labelMap.entries()) {
if (labelKey === otherKey) continue;
const { x, y, width, height } = label;
const {
x: otherX,
y: otherY,
width: otherWidth,
height: otherHeight,
} = otherLabel;
if (
x + width > otherX &&
otherX + otherWidth > x &&
y + height > otherY &&
otherY + otherHeight > y
) {
label.text = getLabelText(label.outlines.concat(otherLabel.outlines));
label.width = ctx.measureText(label.text).width;
labelMap.delete(otherKey);
}
}
}
for (const label of labelMap.values()) {
const { x, y, alpha, width, height, text } = label;
let labelY = y - height - 4;
if (labelY < 0) {
labelY = 0;
}
ctx.fillStyle = `rgba(${PRIMARY_COLOR},${alpha})`;
ctx.fillRect(x, labelY, width + 4, height + 4);
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.fillText(text, x + 2, labelY + height);
}
return activeOutlines.size > 0;
};
================================================
FILE: packages/scan/src/new-outlines/index.ts
================================================
import {
type Fiber,
didFiberCommit,
getDisplayName,
getFiberId,
getNearestHostFibers,
getTimings,
getType,
isCompositeFiber,
} from 'bippy';
import {
Change,
ContextChange,
PropsChange,
ReactScanInternals,
Store,
ignoredProps,
} from '~core/index';
import {
ChangeReason,
createInstrumentation,
getContextChanges,
getStateChanges,
OldRenderData,
} from '~core/instrumentation';
import { log, logIntro } from '~web/utils/log';
import { inspectorUpdateSignal } from '~web/views/inspector/states';
import {
OUTLINE_ARRAY_SIZE,
drawCanvas,
initCanvas,
updateOutlines,
updateScroll,
} from './canvas';
import type { ActiveOutline, BlueprintOutline, OutlineData } from './types';
import { getChangedPropsDetailed } from '~web/views/inspector/utils';
// The worker code will be replaced at build time
const workerCode = '__WORKER_CODE__';
let worker: Worker | null = null;
let canvas: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let dpr = 1;
let animationFrameId: number | null = null;
const activeOutlines = new Map();
const blueprintMap = new Map();
const blueprintMapKeys = new Set();
export const outlineFiber = (fiber: Fiber) => {
if (!isCompositeFiber(fiber)) return;
const name =
typeof fiber.type === 'string' ? fiber.type : getDisplayName(fiber);
if (!name) return;
const blueprint = blueprintMap.get(fiber);
const nearestFibers = getNearestHostFibers(fiber);
const didCommit = didFiberCommit(fiber);
if (!blueprint) {
blueprintMap.set(fiber, {
name,
count: 1,
elements: nearestFibers.map((fiber) => fiber.stateNode),
didCommit: didCommit ? 1 : 0,
});
blueprintMapKeys.add(fiber);
} else {
blueprint.count++;
}
};
const mergeRects = (rects: DOMRect[]) => {
const firstRect = rects[0];
if (rects.length === 1) return firstRect;
let minX: number | undefined;
let minY: number | undefined;
let maxX: number | undefined;
let maxY: number | undefined;
for (let i = 0, len = rects.length; i < len; i++) {
const rect = rects[i];
minX = minX == null ? rect.x : Math.min(minX, rect.x);
minY = minY == null ? rect.y : Math.min(minY, rect.y);
maxX =
maxX == null ? rect.x + rect.width : Math.max(maxX, rect.x + rect.width);
maxY =
maxY == null
? rect.y + rect.height
: Math.max(maxY, rect.y + rect.height);
}
if (minX == null || minY == null || maxX == null || maxY == null) {
return rects[0];
}
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
};
interface IntersectionState {
resolveNext: ((value: IntersectionObserverEntry[]) => void) | null;
seenElements: Set;
uniqueElements: Set;
done: boolean;
}
function onIntersect(
this: IntersectionState,
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) {
const newEntries: IntersectionObserverEntry[] = [];
for (const entry of entries) {
const element = entry.target;
if (!this.seenElements.has(element)) {
this.seenElements.add(element);
newEntries.push(entry);
}
}
if (newEntries.length > 0 && this.resolveNext) {
this.resolveNext(newEntries);
this.resolveNext = null;
}
if (this.seenElements.size === this.uniqueElements.size) {
observer.disconnect();
this.done = true;
if (this.resolveNext) {
this.resolveNext([]);
}
}
}
export const getBatchedRectMap = async function* (
elements: Element[],
): AsyncGenerator {
const state: IntersectionState = {
uniqueElements: new Set(elements),
seenElements: new Set(),
resolveNext: null,
done: false,
};
const observer = new IntersectionObserver(onIntersect.bind(state));
for (const element of state.uniqueElements) {
observer.observe(element);
}
while (!state.done) {
const entries = await new Promise(
(resolve) => {
state.resolveNext = resolve;
},
);
if (entries.length > 0) {
yield entries;
}
}
};
const SupportedArrayBuffer =
typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : ArrayBuffer;
export const flushOutlines = async () => {
const elements: Element[] = [];
for (const fiber of blueprintMapKeys) {
const blueprint = blueprintMap.get(fiber);
if (!blueprint) continue;
for (let i = 0; i < blueprint.elements.length; i++) {
if (!(blueprint.elements[i] instanceof Element)) {
// TODO: filter this at the root
continue;
}
elements.push(blueprint.elements[i]);
}
}
const rectsMap = new Map();
// TODO(Alexis): too complex, needs breakdown
for await (const entries of getBatchedRectMap(elements)) {
for (const entry of entries) {
const element = entry.target;
const rect = entry.intersectionRect;
if (entry.isIntersecting && rect.width && rect.height) {
rectsMap.set(element, rect);
}
}
const blueprints: BlueprintOutline[] = [];
const blueprintRects: DOMRect[] = [];
const blueprintIds: number[] = [];
for (const fiber of blueprintMapKeys) {
const blueprint = blueprintMap.get(fiber);
if (!blueprint) continue;
const rects: DOMRect[] = [];
for (let i = 0; i < blueprint.elements.length; i++) {
const element = blueprint.elements[i];
const rect = rectsMap.get(element);
if (!rect) continue;
rects.push(rect);
}
if (!rects.length) continue;
blueprints.push(blueprint);
blueprintRects.push(mergeRects(rects));
blueprintIds.push(getFiberId(fiber));
}
if (blueprints.length > 0) {
const arrayBuffer = new SupportedArrayBuffer(
blueprints.length * OUTLINE_ARRAY_SIZE * 4,
);
const sharedView = new Float32Array(arrayBuffer);
const blueprintNames = new Array(blueprints.length);
let outlineData: OutlineData[] | undefined;
for (let i = 0, len = blueprints.length; i < len; i++) {
const blueprint = blueprints[i];
const id = blueprintIds[i];
const { x, y, width, height } = blueprintRects[i];
const { count, name, didCommit } = blueprint;
if (worker) {
const scaledIndex = i * OUTLINE_ARRAY_SIZE;
sharedView[scaledIndex] = id;
sharedView[scaledIndex + 1] = count;
sharedView[scaledIndex + 2] = x;
sharedView[scaledIndex + 3] = y;
sharedView[scaledIndex + 4] = width;
sharedView[scaledIndex + 5] = height;
sharedView[scaledIndex + 6] = didCommit;
blueprintNames[i] = name;
} else {
outlineData ||= new Array(blueprints.length);
outlineData[i] = {
id,
name,
count,
x,
y,
width,
height,
didCommit: didCommit as 0 | 1,
};
}
}
if (worker) {
worker.postMessage({
type: 'draw-outlines',
data: arrayBuffer,
names: blueprintNames,
});
} else if (canvas && ctx && outlineData) {
updateOutlines(activeOutlines, outlineData);
if (!animationFrameId) {
animationFrameId = requestAnimationFrame(draw);
}
}
}
}
for (const fiber of blueprintMapKeys) {
blueprintMap.delete(fiber);
blueprintMapKeys.delete(fiber);
}
};
const draw = () => {
if (!ctx || !canvas) return;
const shouldContinue = drawCanvas(ctx, canvas, dpr, activeOutlines);
if (shouldContinue) {
animationFrameId = requestAnimationFrame(draw);
} else {
animationFrameId = null;
}
};
const IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED =
typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
const getDpr = () => {
return Math.min(window.devicePixelRatio || 1, 2);
};
export const getCanvasEl = () => {
cleanup();
const host = document.createElement('div');
host.setAttribute('data-react-scan', 'true');
const shadowRoot = host.attachShadow({ mode: 'open' });
const canvasEl = document.createElement('canvas');
canvasEl.style.position = 'fixed';
canvasEl.style.top = '0';
canvasEl.style.left = '0';
canvasEl.style.pointerEvents = 'none';
canvasEl.style.zIndex = '2147483646';
canvasEl.setAttribute('aria-hidden', 'true');
shadowRoot.appendChild(canvasEl);
if (!canvasEl) return null;
dpr = getDpr();
canvas = canvasEl;
const { innerWidth, innerHeight } = window;
canvasEl.style.width = `${innerWidth}px`;
canvasEl.style.height = `${innerHeight}px`;
const width = innerWidth * dpr;
const height = innerHeight * dpr;
canvasEl.width = width;
canvasEl.height = height;
if (
IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED &&
!window.__REACT_SCAN_EXTENSION__
) {
try {
worker = new Worker(
URL.createObjectURL(
new Blob([workerCode], { type: 'application/javascript' }),
),
);
const offscreenCanvas = canvasEl.transferControlToOffscreen();
worker?.postMessage(
{
type: 'init',
canvas: offscreenCanvas,
width: canvasEl.width,
height: canvasEl.height,
dpr,
},
[offscreenCanvas],
);
} catch (e) {
// oxlint-disable-next-line no-console
console.warn('Failed to initialize OffscreenCanvas worker:', e);
}
}
if (!worker) {
ctx = initCanvas(canvasEl, dpr) as CanvasRenderingContext2D;
}
let isResizeScheduled = false;
window.addEventListener('resize', () => {
if (!isResizeScheduled) {
isResizeScheduled = true;
// TODO(Alexis): bindable
setTimeout(() => {
const width = window.innerWidth;
const height = window.innerHeight;
dpr = getDpr();
canvasEl.style.width = `${width}px`;
canvasEl.style.height = `${height}px`;
if (worker) {
worker.postMessage({
type: 'resize',
width,
height,
dpr,
});
} else {
canvasEl.width = width * dpr;
canvasEl.height = height * dpr;
if (ctx) {
ctx.resetTransform();
ctx.scale(dpr, dpr);
}
draw();
}
isResizeScheduled = false;
});
}
});
let prevScrollX = window.scrollX;
let prevScrollY = window.scrollY;
let isScrollScheduled = false;
window.addEventListener('scroll', () => {
if (!isScrollScheduled) {
isScrollScheduled = true;
// TODO(Alexis): bindable
setTimeout(() => {
const { scrollX, scrollY } = window;
const deltaX = scrollX - prevScrollX;
const deltaY = scrollY - prevScrollY;
prevScrollX = scrollX;
prevScrollY = scrollY;
if (worker) {
worker.postMessage({
type: 'scroll',
deltaX,
deltaY,
});
} else {
requestAnimationFrame(
updateScroll.bind(null, activeOutlines, deltaX, deltaY),
);
}
isScrollScheduled = false;
}, 16 * 2);
}
});
setInterval(() => {
if (blueprintMapKeys.size) {
requestAnimationFrame(flushOutlines);
}
}, 16 * 2);
shadowRoot.appendChild(canvasEl);
return host;
};
export const hasStopped = () => {
return globalThis.__REACT_SCAN_STOP__;
};
export const stop = () => {
globalThis.__REACT_SCAN_STOP__ = true;
cleanup();
};
export const cleanup = () => {
const host = document.querySelector('[data-react-scan]');
if (host) {
host.remove();
}
};
const reportRenderToListeners = (fiber: Fiber) => {
if (isCompositeFiber(fiber)) {
// report render has a non trivial cost because it calls Date.now(), so we want to avoid the computation if possible
if (
ReactScanInternals.options.value.showToolbar !== false &&
Store.inspectState.value.kind === 'focused'
) {
const reportFiber = fiber;
const { selfTime } = getTimings(fiber);
const displayName = getDisplayName(fiber.type);
const fiberId = getFiberId(reportFiber);
const currentData = Store.reportData.get(fiberId);
const existingCount = currentData?.count ?? 0;
const existingTime = currentData?.time ?? 0;
const changes: Array = [];
// optimization, for now only track changes on inspected prop, cleanup later when changes is used in outline drawing
const listeners = Store.changesListeners.get(getFiberId(fiber));
if (listeners?.length) {
const propsChanges: Array = getChangedPropsDetailed(
fiber,
).map((change) => ({
type: ChangeReason.Props,
name: change.name,
value: change.value,
prevValue: change.prevValue,
unstable: false,
}));
const stateChanges = getStateChanges(fiber);
// context changes are incorrect, bippy needs to tell us the context dependencies that changed and provide those values every render
// currently, we say every context change, regardless of the render it happened, is a change. Which requires us to hack change tracking
// in the whats-changed toolbar component
const fiberContext = getContextChanges(fiber);
const contextChanges: Array = fiberContext.map(
(info) => ({
name: info.name,
type: ChangeReason.Context,
value: info.value,
contextType: info.contextType,
}),
);
listeners.forEach((listener) => {
listener({
propsChanges,
stateChanges,
contextChanges,
});
});
}
const fiberData: OldRenderData = {
count: existingCount + 1,
time: existingTime + selfTime || 0,
renders: [],
displayName,
type: getType(fiber.type) || null,
changes,
};
Store.reportData.set(fiberId, fiberData);
needsReport = true;
}
}
};
let needsReport = false;
let reportInterval: ReturnType;
export const startReportInterval = () => {
clearInterval(reportInterval);
reportInterval = setInterval(() => {
if (needsReport) {
Store.lastReportTime.value = Date.now();
needsReport = false;
}
}, 50);
};
export const isValidFiber = (fiber: Fiber) => {
if (ignoredProps.has(fiber.memoizedProps)) {
return false;
}
return true;
};
let isInstrumentationInitialized = false;
export const initReactScanInstrumentation = (setupToolbar: () => void) => {
if (hasStopped()) return;
if (isInstrumentationInitialized) return;
isInstrumentationInitialized = true;
// todo: don't hardcode string getting weird ref error in iife when using process.env
let schedule: ReturnType;
let mounted = false;
const scheduleSetup = () => {
if (mounted) {
return;
}
if (schedule) {
cancelAnimationFrame(schedule);
}
schedule = requestAnimationFrame(() => {
mounted = true;
const host = getCanvasEl();
if (host) {
document.documentElement.appendChild(host);
}
setupToolbar();
}); // TODO(Alexis): perhaps a better timing
};
const instrumentation = createInstrumentation('react-scan-devtools-0.1.0', {
onCommitStart: () => {
ReactScanInternals.options.value.onCommitStart?.();
},
onActive: (() => {
let didActivate = false;
return () => {
if (hasStopped()) return;
if (didActivate) return;
didActivate = true;
scheduleSetup();
if (!window.__REACT_SCAN_EXTENSION__) {
globalThis.__REACT_SCAN__ = {
ReactScanInternals,
};
}
startReportInterval();
logIntro();
};
})(),
onError: () => {
// todo: ingest errors without accidentally collecting data about user
},
isValidFiber,
onRender: (fiber, renders) => {
if (isCompositeFiber(fiber)) {
Store.interactionListeningForRenders?.(fiber, renders);
}
const isOverlayPaused =
ReactScanInternals.instrumentation?.isPaused.value;
const isInspectorInactive =
Store.inspectState.value.kind === 'inspect-off' ||
Store.inspectState.value.kind === 'uninitialized';
const shouldFullyAbort = isOverlayPaused && isInspectorInactive;
if (shouldFullyAbort) {
return;
}
if (!isOverlayPaused) {
outlineFiber(fiber);
}
if (ReactScanInternals.options.value.log) {
// this can be expensive given enough re-renders
log(renders);
}
if (Store.inspectState.value.kind === 'focused') {
inspectorUpdateSignal.value = Date.now();
}
if (!isInspectorInactive) {
reportRenderToListeners(fiber);
}
ReactScanInternals.options.value.onRender?.(fiber, renders);
},
onCommitFinish: () => {
scheduleSetup();
ReactScanInternals.options.value.onCommitFinish?.();
},
onPostCommitFiberRoot() {
scheduleSetup();
},
trackChanges: false,
});
ReactScanInternals.instrumentation = instrumentation;
};
================================================
FILE: packages/scan/src/new-outlines/offscreen-canvas.worker.ts
================================================
import { OUTLINE_ARRAY_SIZE, drawCanvas, initCanvas } from './canvas';
import type { ActiveOutline } from './types';
let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let dpr = 1;
const activeOutlines: Map = new Map();
let animationFrameId: number | null = null;
const draw = () => {
if (!ctx || !canvas) return;
const shouldContinue = drawCanvas(ctx, canvas, dpr, activeOutlines);
if (shouldContinue) {
animationFrameId = requestAnimationFrame(draw);
} else {
animationFrameId = null;
}
};
self.onmessage = (event) => {
const { type } = event.data;
if (type === 'init') {
canvas = event.data.canvas;
dpr = event.data.dpr;
if (canvas) {
canvas.width = event.data.width;
canvas.height = event.data.height;
ctx = initCanvas(canvas, dpr) as OffscreenCanvasRenderingContext2D;
}
}
if (!canvas || !ctx) return;
if (type === 'resize') {
dpr = event.data.dpr;
canvas.width = event.data.width * dpr;
canvas.height = event.data.height * dpr;
ctx.resetTransform();
ctx.scale(dpr, dpr);
draw();
return;
}
if (type === 'draw-outlines') {
const { data, names } = event.data;
const sharedView = new Float32Array(data);
for (let i = 0; i < sharedView.length; i += OUTLINE_ARRAY_SIZE) {
const x = sharedView[i + 2];
const y = sharedView[i + 3];
const width = sharedView[i + 4];
const height = sharedView[i + 5];
const didCommit = sharedView[i + 6] as 0 | 1;
const outline = {
id: sharedView[i],
name: names[i / OUTLINE_ARRAY_SIZE],
count: sharedView[i + 1],
x,
y,
width,
height,
frame: 0,
targetX: x,
targetY: y,
targetWidth: width,
targetHeight: height,
didCommit,
};
const key = String(outline.id);
const existingOutline = activeOutlines.get(key);
if (existingOutline) {
existingOutline.count++;
existingOutline.frame = 0;
existingOutline.targetX = x;
existingOutline.targetY = y;
existingOutline.targetWidth = width;
existingOutline.targetHeight = height;
existingOutline.didCommit = didCommit;
} else {
activeOutlines.set(key, outline);
}
}
if (!animationFrameId) {
animationFrameId = requestAnimationFrame(draw);
}
return;
}
if (type === 'scroll') {
const { deltaX, deltaY } = event.data;
for (const outline of activeOutlines.values()) {
const newX = outline.x - deltaX;
const newY = outline.y - deltaY;
outline.targetX = newX;
outline.targetY = newY;
}
}
};
================================================
FILE: packages/scan/src/new-outlines/types.ts
================================================
export interface OutlineData {
id: number;
name: string;
count: number;
x: number;
y: number;
width: number;
height: number;
didCommit: 0 | 1;
}
export type InlineOutlineData = [
id: number,
count: number,
x: number,
y: number,
width: number,
height: number,
didCommit: 0 | 1,
];
export interface ActiveOutline {
id: number;
name: string;
count: number;
x: number;
y: number;
width: number;
height: number;
targetX: number;
targetY: number;
targetWidth: number;
targetHeight: number;
frame: number;
didCommit: 1 | 0;
}
export interface BlueprintOutline {
name: string;
count: number;
elements: Element[];
didCommit: 1 | 0;
}
declare global {
var __REACT_SCAN_STOP__: boolean;
var ReactScan: {
hasStopped: () => boolean;
stop: () => void;
cleanup: () => void;
init: () => void;
flushOutlines: () => void;
};
}
================================================
FILE: packages/scan/src/polyfills.ts
================================================
if (!Array.prototype.toSorted) {
Object.defineProperty(Array.prototype, 'toSorted', {
value: function (this: Array, compareFn?: (a: T, b: T) => number): Array {
return [...this].sort(compareFn);
},
writable: true,
configurable: true,
});
}
================================================
FILE: packages/scan/src/react-component-name/__tests__/arrow-function.test.ts
================================================
import { describe, it, expect } from 'vitest';
import { transform } from './utils';
describe('arrow function components', () => {
it('handles inline JSX return', async () => {
const input = `
export const Button = () => Click
`;
const result = await transform(input);
expect(result).toContain("Button.displayName = 'Button'");
});
it('handles block with JSX return', async () => {
const input = `
const Modal = () => {
return Modal content
}
`;
const result = await transform(input);
expect(result).toContain("Modal.displayName = 'Modal'");
});
it('handles conditional returns', async () => {
const input = `
const ConditionalComponent = ({ show }) => {
if (show) {
return Shown
}
return Hidden
}
`;
const result = await transform(input);
expect(result).toContain(
"ConditionalComponent.displayName = 'ConditionalComponent'",
);
});
it('handles early returns', async () => {
const input = `
const EarlyReturn = ({ loading, error, data }) => {
if (loading) return Loading...
if (error) return Error: {error}
return {data}
}
`;
const result = await transform(input);
expect(result).toContain("EarlyReturn.displayName = 'EarlyReturn'");
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/complex-patterns.test.ts
================================================
import { describe, it, expect } from 'vitest';
import { transform } from './utils';
describe('complex component patterns', () => {
it('handles components with hooks', async () => {
const input = `
const TodoList = () => {
const [todos, setTodos] = useState([])
useEffect(() => {
fetchTodos().then(setTodos)
}, [])
return {todos.map(todo => {todo.text} )}
}
`;
const result = await transform(input);
expect(result).toContain("TodoList.displayName = 'TodoList'");
});
it('handles components with multiple state updates', async () => {
const input = `
const Counter = () => {
const [count, setCount] = useState(0)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
return (
-
{count}
+
)
}
`;
const result = await transform(input);
expect(result).toContain("Counter.displayName = 'Counter'");
});
it('handles components with render props', async () => {
const input = `
const DataFetcher = ({ children, url }) => {
const [data, setData] = useState(null)
useEffect(() => {
fetch(url).then(setData)
}, [url])
return <>{children(data)}>
}
`;
const result = await transform(input);
expect(result).toContain("DataFetcher.displayName = 'DataFetcher'");
});
it('handles higher-order components', async () => {
const input = `
const withData = (WrappedComponent) => {
const WithData = (props) => {
const [data, setData] = useState(null)
return
}
return WithData
}
`;
const result = await transform(input);
expect(result).toContain("WithData.displayName = 'WithData'");
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/function-declarations.test.ts
================================================
import { describe, it, expect } from 'vitest';
import { transform } from './utils';
describe('function declarations', () => {
it('handles named function declarations', async () => {
const input = `
function Welcome(props) {
return Hello, {props.name}
}
`;
const result = await transform(input);
expect(result).toContain("Welcome.displayName = 'Welcome'");
});
it('handles async components', async () => {
const input = `
async function AsyncComponent({ id }) {
const data = await fetchData(id)
return {data}
}
`;
const result = await transform(input);
expect(result).toContain("AsyncComponent.displayName = 'AsyncComponent'");
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/general-cases.test.ts
================================================
import { describe, expect, it } from 'vitest';
import { transform } from './utils';
describe('edge cases', () => {
it('handles nested component declarations', async () => {
const input = `
const Parent = () => {
const NestedChild = () => Child
return (
)
}
`;
const result = await transform(input);
expect(result).toContain("Parent.displayName = 'Parent'");
expect(result).toContain("NestedChild.displayName = 'NestedChild'");
});
it('handles components with complex expressions', async () => {
const input = `
const DynamicComponent = () => {
const content = useMemo(() => (
{data.map(item => (
{item.visible && {item.text} }
))}
), [data])
return (
<>
{isLoading ? : content}
>
)
}
`;
const result = await transform(input);
expect(result).toContain(
"DynamicComponent.displayName = 'DynamicComponent'",
);
});
it('handles components with multiple returns in switch/case', async () => {
const input = `
const StatusComponent = ({ status }) => {
switch (status) {
case 'loading':
return
case 'error':
return
case 'empty':
return
default:
return
}
}
`;
const result = await transform(input);
expect(result).toContain("StatusComponent.displayName = 'StatusComponent'");
});
it('handles components with try/catch blocks', async () => {
const input = `
const SafeComponent = () => {
try {
const data = riskyOperation()
return {data}
} catch (error) {
return Error: {error.message}
}
}
`;
const result = await transform(input);
expect(result).toContain("SafeComponent.displayName = 'SafeComponent'");
});
it('handles components returning primitive values', async () => {
const input = `
// Null component
const EmptyComponent = () => null;
// String component
const TextComponent = () => "Hello World";
// Number component
const NumberComponent = () => 42;
// Boolean component (though not very useful)
const BooleanComponent = () => true;
// Array of elements
const ListComponent = () => [
One
,
Two
];
// Conditional primitive returns
const ConditionalComponent = ({ value }) => {
if (!value) return null;
if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString();
return {value}
;
};
// Dynamic children
const DynamicComponent = ({ count }) => {
return Array(count).fill(null).map((_, i) =>
);
};
// Async component with suspense
const AsyncComponent = () => {
const resource = fetchData();
if (!resource.isReady) {
throw resource.promise;
}
return {resource.read()}
;
};
// Portal component
const PortalComponent = () => {
return createPortal(Portal content
, document.body);
};
// Fragment shorthand
const FragmentComponent = () => <>Fragment content>;
// Nested arrays and fragments
const NestedComponent = () => [
First
,
<>
Nested 1
Nested 2
>,
[Deep nested
]
];
`;
const result = await transform(input);
// expect(result).toContain("EmptyComponent.displayName = 'EmptyComponent'");
// expect(result).toContain("TextComponent.displayName = 'TextComponent'");
// expect(result).toContain("NumberComponent.displayName = 'NumberComponent'");
// expect(result).toContain(
// "BooleanComponent.displayName = 'BooleanComponent'",
// );
expect(result).toContain("ListComponent.displayName = 'ListComponent'");
expect(result).toContain(
"ConditionalComponent.displayName = 'ConditionalComponent'",
);
expect(result).toContain(
"DynamicComponent.displayName = 'DynamicComponent'",
);
expect(result).toContain("AsyncComponent.displayName = 'AsyncComponent'");
expect(result).toContain("PortalComponent.displayName = 'PortalComponent'");
expect(result).toContain(
"FragmentComponent.displayName = 'FragmentComponent'",
);
expect(result).toContain("NestedComponent.displayName = 'NestedComponent'");
});
it('handles components with complex conditional returns', async () => {
const input = `
const ComplexComponent = ({ type, data }) => {
switch (type) {
case 'text': return data;
case 'number': return data.toString();
case 'array': return data.map(item => {item.text}
);
case 'element': return {data}
;
default: return null;
}
};
const TernaryComponent = ({ condition, value }) =>
condition
? value
: value
? {value}
: null;
const ShortCircuitComponent = ({ items }) =>
items?.length && items.map(item => {item}
);
const NullishComponent = ({ text }) =>
text ?? <>Default text>;
const ChainedComponent = ({ a, b, c }) =>
a?.b?.c ?? Fallback
;
// More Suspense examples
const DataComponent = () => {
const data = resource.read();
return {data}
;
};
const SuspenseImage = ({ src }) => {
const resource = preloadImage(src);
if (!resource.complete) {
throw resource.promise;
}
return ;
};
const ProfileComponent = () => {
const user = userResource.read();
const posts = postsResource.read();
if (!user || !posts) {
throw Promise.all([user?.promise, posts?.promise]);
}
return (
{user.name}
{posts.map(post =>
{post.title}
)}
);
};
`;
const result = await transform(input);
expect(result).toContain(
"ComplexComponent.displayName = 'ComplexComponent'",
);
expect(result).toContain(
"TernaryComponent.displayName = 'TernaryComponent'",
);
expect(result).toContain(
"ShortCircuitComponent.displayName = 'ShortCircuitComponent'",
);
expect(result).toContain(
"NullishComponent.displayName = 'NullishComponent'",
);
expect(result).toContain(
"ChainedComponent.displayName = 'ChainedComponent'",
);
expect(result).toContain("DataComponent.displayName = 'DataComponent'");
expect(result).toContain("SuspenseImage.displayName = 'SuspenseImage'");
expect(result).toContain(
"ProfileComponent.displayName = 'ProfileComponent'",
);
});
it('handles components with complex state and hooks', async () => {
const input = `
export const ValueUpdate = ({
valueUpdate,
className,
}) => {
const actions = useTraceStoreActions();
const referredToHeapObject = useTraceStore(getReferredToHeapObjectSelector(valueUpdate));
const constructorStackFrame = useTraceStore(
referredToHeapObject ? constructorStackFrameSelector(referredToHeapObject.constructorStackFrameId) : () => null,
);
const referredToHeapObjectColor = constructorStackFrame ? getFrameColor(constructorStackFrame) : undefined;
return (
{
actions.shared.deleteValueUpdate(valueUpdate.valueUpdateId);
},
onValueChange: (newValue) => {
actions.shared.changeVariableUpdateValue({
value: newValue,
valueUpdateId: valueUpdate.valueUpdateId,
});
},
}}
/>
);
};
// Another example with complex state management
const DataGrid = ({ data, onSort }) => {
const [sortField, setSortField] = useState(null);
const [sortDirection, setSortDirection] = useState('asc');
const [filters, setFilters] = useState({});
const sortedData = useMemo(() => {
if (!sortField) return data;
return [...data].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
});
}, [data, sortField, sortDirection]);
const filteredData = useMemo(() => {
return sortedData.filter(item => {
return Object.entries(filters).every(([key, value]) => {
return item[key].toString().toLowerCase().includes(value.toLowerCase());
});
});
}, [sortedData, filters]);
const handleHeaderClick = (field) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
onSort?.({ field, direction: sortDirection });
};
return (
{Object.keys(data[0] || {}).map(field => (
handleHeaderClick(field)}
className={cn([
'header-cell',
sortField === field && 'sorted',
sortField === field && sortDirection === 'desc' && 'desc'
])}
>
{field}
))}
{filteredData.map((row, i) => (
{Object.values(row).map((cell, j) => (
{cell}
))}
))}
);
};
`;
const result = await transform(input);
expect(result).toContain("ValueUpdate.displayName = 'ValueUpdate'");
expect(result).toContain("DataGrid.displayName = 'DataGrid'");
});
it('handles all forwardRef patterns', async () => {
const input = `
import React from 'react';
// Basic forwardRef
const Button = React.forwardRef((props, ref) => (
));
// Named function in forwardRef
const Input = React.forwardRef(function Input(props, ref) {
return ;
});
// forwardRef with type annotations
const Select = React.forwardRef((props, ref) => (
));
// forwardRef with displayName already set (should preserve it)
const TextArea = React.forwardRef((props, ref) => {
return ;
});
TextArea.displayName = 'CustomTextArea';
// Complex forwardRef with hooks and logic
const Field = React.forwardRef((props, ref) => {
const [value, setValue] = useState('');
const internalRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => internalRef.current?.focus(),
reset: () => setValue('')
}));
return (
setValue(e.target.value)}
/>
);
});
// forwardRef with memo
const MemoizedInput = React.memo(React.forwardRef((props, ref) => (
)));
// forwardRef wrapped in HOC
const EnhancedInput = withStyles(React.forwardRef((props, ref) => (
)));
`;
const result = await transform(input);
expect(result).toContain("Button.displayName = 'Button'");
expect(result).toContain("Input.displayName = 'Input'");
expect(result).toContain("Select.displayName = 'Select'");
expect(result).toContain("TextArea.displayName = 'CustomTextArea'"); // Should preserve existing, todo check for one
expect(result).toContain("Field.displayName = 'Field'");
expect(result).toContain("MemoizedInput.displayName = 'MemoizedInput'");
expect(result).toContain("EnhancedInput.displayName = 'EnhancedInput'");
});
it('handles all memo patterns', async () => {
const input = `
import React from 'react';
// Basic memo
const Item = React.memo(props => (
{props.text}
));
// Named function in memo
const Header = React.memo(function Header({ title }) {
return {title} ;
});
// memo with comparison function
const ExpensiveList = React.memo(({ items }) => (
{items.map(item => {item.text} )}
), (prevProps, nextProps) => prevProps.items === nextProps.items);
// memo with type annotations
const TypedButton = React.memo(props => (
));
// memo with displayName already set (should preserve it)
const Footer = React.memo(props => (
));
Footer.displayName = 'CustomFooter';
// Complex memo with hooks and logic
const SearchBar = React.memo(({ onSearch }) => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
onSearch(debouncedQuery);
}, [debouncedQuery, onSearch]);
return (
setQuery(e.target.value)}
/>
);
});
// Nested memo
const NestedMemo = React.memo(React.memo(props => (
{props.text}
)));
// memo wrapped in HOC
const EnhancedList = withStyles(React.memo(props => (
{props.items.map(item => {item.text} )}
)));
// memo with forwardRef
const MemoInput = React.memo(React.forwardRef((props, ref) => (
)));
`;
const result = await transform(input);
expect(result).toContain("Item.displayName = 'Item'");
expect(result).toContain("Header.displayName = 'Header'");
expect(result).toContain("ExpensiveList.displayName = 'ExpensiveList'");
expect(result).toContain("TypedButton.displayName = 'TypedButton'");
expect(result).toContain("Footer.displayName = 'CustomFooter'"); // Should preserve existing
expect(result).toContain("SearchBar.displayName = 'SearchBar'");
expect(result).toContain("NestedMemo.displayName = 'NestedMemo'");
expect(result).toContain("EnhancedList.displayName = 'EnhancedList'");
expect(result).toContain("MemoInput.displayName = 'MemoInput'");
});
it('handles components with various function calls returning JSX', async () => {
const input = `
const ArrayMethodsComponent = ({ items }) => {
// Filter then map
const filtered = items
.filter(item => item.visible)
.map(item => {item.text}
);
// Reduce to JSX
const reduced = items.reduce((acc, item) => [
...acc,
{item.text}
], []);
// Custom function returning JSX
const renderItem = (item) => {item.text}
;
// Method chaining with JSX returns
const processed = items
.slice(0, 5)
.filter(item => item.score > 10)
.map(renderItem);
return (
<>
{filtered}
{reduced}
{processed}
{renderItem(items[0])}
>
);
};
// Custom utility functions returning JSX
const renderList = (items) => items.map(item => {item.text} );
const createWrapper = (content) => {content}
;
const withLayout = (Component) => (props) => (
);
const CustomFunctionsComponent = ({ items }) => {
// Direct function calls returning JSX
const list = renderList(items);
const wrapped = createWrapper(Content );
// Function composition
const content = createWrapper(renderList(items));
// HOC usage
const WrappedComponent = withLayout(({ text }) => {text}
);
return (
<>
{list}
{wrapped}
{content}
>
);
};
// Promise/async function returns
const AsyncComponent = ({ id }) => {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const result = await api.get(id);
return {result.data}
;
};
fetchData().then(setData);
}, [id]);
return data || Loading...
;
};
// Complex method chaining
const ChainedComponent = ({ data }) => {
const result = Object.entries(data)
.filter(([_, value]) => value.isValid)
.map(([key, value]) => ({ key, ...value }))
.reduce((acc, item) => ({
...acc,
[item.key]: {item.content}
}), {});
return (
{Object.values(result)}
);
};
// Functional composition
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
const withData = Component => props => {
const data = useData();
return ;
};
const withTheme = Component => props => {
const theme = useTheme();
return ;
};
const BaseComponent = ({ data, theme, label }) => (
{data[label]}
);
const EnhancedComponent = compose(
withData,
withTheme
)(BaseComponent);
`;
const result = await transform(input);
expect(result).toContain(
"ArrayMethodsComponent.displayName = 'ArrayMethodsComponent'",
);
expect(result).toContain(
"CustomFunctionsComponent.displayName = 'CustomFunctionsComponent'",
);
expect(result).toContain("AsyncComponent.displayName = 'AsyncComponent'");
expect(result).toContain(
"ChainedComponent.displayName = 'ChainedComponent'",
);
expect(result).toContain("BaseComponent.displayName = 'BaseComponent'");
// expect(result).toContain(
// "EnhancedComponent.displayName = 'EnhancedComponent'",
// );
});
it('handles shadcn-style component patterns', async () => {
const input = `
import React from 'react';
// Basic shadcn component pattern
const Button = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes
>(({ className, ...props }, ref) => (
));
// With variants using cva
const button = cva(
"rounded-lg px-4",
{
variants: {
variant: {
default: "bg-primary",
secondary: "bg-secondary",
},
size: {
default: "h-10",
sm: "h-8",
lg: "h-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {}
const ButtonWithVariants = React.forwardRef(
({ className, variant, size, ...props }, ref) => {
return (
)
}
);
// With slot compositions
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
// Component composition
const Dialog = ({ children, ...props }) => (
{children}
);
const DialogTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
`;
const result = await transform(input);
expect(result).toContain("Button.displayName = 'Button'");
expect(result).toContain(
"ButtonWithVariants.displayName = 'ButtonWithVariants'",
);
expect(result).toContain("Card.displayName = 'Card'");
expect(result).toContain("CardHeader.displayName = 'CardHeader'");
expect(result).toContain("Dialog.displayName = 'Dialog'");
expect(result).toContain("DialogTrigger.displayName = 'DialogTrigger'");
});
it('handles legacy and unconventional component patterns', async () => {
const input = `
// createReactClass (after createClass was removed from React)
const CreateClassComponent = createReactClass({
render() {
return Still Legacy
}
});
// Mixins (old pattern, but still exists)
const mixins = {
componentDidMount() {
console.log('mounted');
}
};
const WithMixins = createReactClass({
mixins: [mixins],
render() {
return With Mixins
}
});
// Factory pattern (common in older Material-UI and other libs)
const createComponent = (config) => {
class GeneratedComponent extends React.Component {
render() {
return {config.text}
}
}
return GeneratedComponent;
};
const FactoryComponent = createComponent({ text: 'Factory' });
// Decorator pattern (still common in MobX codebases)
@observer
class DecoratedComponent extends React.Component {
render() {
return {this.props.data}
}
}
// Render props with multiple children functions
const RenderPropComponent = ({ children, render, component: Component }) => (
{children(data)}
{render(data)}
);
// Old context pattern
class OldContextComponent extends React.Component {
static contextTypes = {
theme: PropTypes.object
};
render() {
return {this.context.theme}
}
}
// Partial application component creation
const createPartialComponent = (defaultProps) =>
function PartialComponent(props) {
return
;
};
const PartialButton = createPartialComponent({ type: 'button' });
// jQuery-style plugins (seen in older React codebases)
React.Component.prototype.plugin = function() {
return Plugin
;
};
class PluginComponent extends React.Component {
render() {
return <>{this.plugin()}>;
}
}
// Multiple inheritance simulation
const withInheritance = Base => class extends Base {
render() {
return Extended {super.render()}
;
}
};
class BaseComponent extends React.Component {
render() {
return Base
;
}
}
const InheritedComponent = withInheritance(BaseComponent);
`;
const result = await transform(input);
expect(result).toContain(
"CreateClassComponent.displayName = 'CreateClassComponent'",
);
expect(result).toContain("WithMixins.displayName = 'WithMixins'");
// expect(result).toContain(
// "FactoryComponent.displayName = 'FactoryComponent'",
// );
expect(result).toContain(
"DecoratedComponent.displayName = 'DecoratedComponent'",
);
expect(result).toContain(
"RenderPropComponent.displayName = 'RenderPropComponent'",
);
expect(result).toContain(
"OldContextComponent.displayName = 'OldContextComponent'",
);
// expect(result).toContain("PartialButton.displayName = 'PartialButton'");
expect(result).toContain("PluginComponent.displayName = 'PluginComponent'");
// expect(result).toContain(
// "InheritedComponent.displayName = 'InheritedComponent'",
// );
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/react-patterns.test.ts
================================================
import { describe, it, expect } from 'vitest';
import { transform } from './utils';
describe('modern React patterns', () => {
it('handles components with hooks and context', async () => {
const input = `
const UserProfile = () => {
const { user } = useContext(UserContext)
const { theme } = useContext(ThemeContext)
return (
)
}
`;
const result = await transform(input);
expect(result).toContain("UserProfile.displayName = 'UserProfile'");
});
it('handles components with custom hooks', async () => {
const input = `
const SearchResults = () => {
const { data, loading, error } = useQuery(SEARCH_QUERY)
const { formatResult } = useSearchFormatter()
if (loading) return Loading...
if (error) return Error!
return (
{data.map(item => (
{formatResult(item)}
))}
)
}
`;
const result = await transform(input);
expect(result).toContain("SearchResults.displayName = 'SearchResults'");
});
it('handles components with suspense boundaries', async () => {
const input = `
const AsyncContent = () => {
const data = useSuspenseQuery(QUERY)
return (
Loading...}>
{data.content}
)
}
`;
const result = await transform(input);
expect(result).toContain("AsyncContent.displayName = 'AsyncContent'");
});
it('handles components with error boundaries', async () => {
const input = `
import React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false }
static getDerivedStateFromError(error) {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return Something went wrong
}
return this.props.children
}
}
`;
const result = await transform(input);
expect(result).toContain("ErrorBoundary.displayName = 'ErrorBoundary'");
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/ts-patterns.test.ts
================================================
import { describe, it, expect } from 'vitest';
import { transform } from './utils';
describe('typescript patterns', () => {
it('handles components with type parameters', async () => {
const input = `
interface Props {
items: T[]
renderItem: (item: T) => React.ReactNode
}
const List = ({ items, renderItem }: Props) => {
return {items.map(renderItem)}
}
`;
const result = await transform(input);
expect(result).toContain("List.displayName = 'List'");
});
it('handles components with complex types', async () => {
const input = `
type Props = {
id: string
onClick: (e: React.MouseEvent) => void
children: React.ReactNode
} & Omit, 'onClick'>
export const Button: React.FC = ({ id, onClick, children, ...rest }) => {
return {children}
}
`;
const result = await transform(input);
expect(result).toContain("Button.displayName = 'Button'");
});
});
================================================
FILE: packages/scan/src/react-component-name/__tests__/utils.ts
================================================
import { type Options, reactComponentNamePlugin } from '..';
type TransformFn = (
code: string,
id: string,
) => Promise<{ code: string } | string | null>;
export const transform = async (code: string, options?: Options) => {
const plugin = reactComponentNamePlugin.vite(options || {}) as {
transform: TransformFn;
};
const transformFn = plugin.transform;
if (!transformFn) return code;
const result = await transformFn.call(
{
getCombinedSourcemap: () => null,
error: console.error,
},
code,
'test.tsx',
);
if (!result) return code;
if (typeof result === 'string') return result;
return result.code;
};
================================================
FILE: packages/scan/src/react-component-name/astro.ts
================================================
import type { Options } from '.';
import vite from './vite';
export default (options: Options = {}) => ({
name: 'react-component-name',
hooks: {
// oxlint-disable-next-line typescript/no-explicit-any
'astro:config:setup': (astro: any) => {
astro.config.vite.plugins ||= [];
astro.config.vite.plugins.push(vite(options));
},
},
});
================================================
FILE: packages/scan/src/react-component-name/babel/get-descriptive-name.ts
================================================
import type * as babel from '@babel/core';
export function getDescriptiveName(
path: babel.NodePath,
defaultName: string,
): string {
let current: babel.NodePath | null = path;
while (current) {
switch (current.node.type) {
case 'FunctionDeclaration':
case 'FunctionExpression': {
if (current.node.id) {
return current.node.id.name;
}
break;
}
case 'VariableDeclarator': {
if (current.node.id.type === 'Identifier') {
return current.node.id.name;
}
break;
}
case 'ClassPrivateMethod':
case 'ClassMethod':
case 'ObjectMethod': {
switch (current.node.key.type) {
case 'Identifier':
return current.node.key.name;
case 'PrivateName':
return current.node.key.id.name;
default:
break;
}
break;
}
default:
break;
}
current = current.parentPath;
}
return defaultName;
}
================================================
FILE: packages/scan/src/react-component-name/babel/get-root-statement-path.ts
================================================
import type * as babel from '@babel/core';
import * as t from '@babel/types';
export function getRootStatementPath(path: babel.NodePath): babel.NodePath {
let current = path.parentPath;
while (current) {
const next = current.parentPath;
if (next && t.isProgram(next.node)) {
return current;
}
current = next;
}
return path;
}
================================================
FILE: packages/scan/src/react-component-name/babel/index.ts
================================================
import type { NodePath, PluginObj } from '@babel/core';
import * as t from '@babel/types';
import type { Options } from '../core/options';
import { isComponentishName } from './is-componentish-name';
import { pathReferencesImport } from './path-references-import';
import { unwrapNode, unwrapPath } from './unwrap';
function getAssignedDisplayNames(path: NodePath): Set {
const names = new Set();
path.traverse({
AssignmentExpression(path) {
const { node } = path;
const memberExpr = unwrapNode(node.left, t.isMemberExpression);
if (!memberExpr) {
return;
}
const object = unwrapNode(memberExpr.object, t.isIdentifier);
if (!object) {
return;
}
if (
t.isIdentifier(memberExpr.property) &&
memberExpr.property.name === 'displayName'
) {
names.add(object.name);
}
},
});
return names;
}
function isValidFunction(
node: t.Node,
): node is t.ArrowFunctionExpression | t.FunctionExpression {
return t.isArrowFunctionExpression(node) || t.isFunctionExpression(node);
}
function assignDisplayName(
statement: NodePath,
name: string,
dontAddTryCatch = false,
): void {
if (dontAddTryCatch) {
statement.insertAfter([
t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(t.identifier(name), t.identifier('displayName')),
t.stringLiteral(name),
),
),
]);
} else {
statement.insertAfter([
t.tryStatement(
t.blockStatement([
t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(
t.identifier(name),
t.identifier('displayName'),
),
t.stringLiteral(name),
),
),
]),
t.catchClause(t.identifier('error'), t.blockStatement([])),
),
]);
}
}
const REACT_CLASS = ['Component', 'PureComponent'];
function isNamespaceExport(
namespace: string,
moduleExports: string[],
path: NodePath,
): boolean {
const identifier = unwrapPath(path, t.isIdentifier);
if (identifier) {
return moduleExports.includes(identifier.node.name);
}
const memberExpr = unwrapPath(path, t.isMemberExpression);
if (memberExpr) {
const object = unwrapPath(memberExpr.get('object'), t.isIdentifier);
if (object && object.node.name === namespace) {
const property = memberExpr.get('property');
return (
property.isIdentifier() && moduleExports.includes(property.node.name)
);
}
}
return false;
}
function isReactClassComponent(path: NodePath): boolean {
const superClass = path.get('superClass');
if (!superClass.isExpression()) {
return false;
}
if (isNamespaceExport('React', REACT_CLASS, superClass)) {
return true;
}
// The usual
if (pathReferencesImport(superClass, 'react', REACT_CLASS, false, true)) {
return true;
}
return false;
}
function isStyledComponent(
moduleName: string,
importName: string[],
path: NodePath,
): boolean {
function isStyledImport(path: NodePath): boolean {
return (
(path.isIdentifier() && path.node.name === 'styled') ||
pathReferencesImport(path, moduleName, importName, false, false)
);
}
const callExpr = unwrapPath(path, t.isCallExpression);
if (callExpr) {
const callee = callExpr.get('callee');
// styled('h1', () => {...});
if (isStyledImport(callee)) {
return true;
}
// styled.h1(() => {...})
const memberExpr = unwrapPath(callee, t.isMemberExpression);
if (memberExpr) {
const object = unwrapPath(memberExpr.get('object'), t.isIdentifier);
if (object && isStyledImport(object)) {
return true;
}
}
return false;
}
const taggedExpr = unwrapPath(path, t.isTaggedTemplateExpression);
if (taggedExpr) {
const tag = taggedExpr.get('tag');
const memberExpr = unwrapPath(tag, t.isMemberExpression);
if (memberExpr) {
const object = unwrapPath(memberExpr.get('object'), t.isIdentifier);
// styled.h1`...`;
if (object && isStyledImport(object)) {
return true;
}
return false;
}
// styled(Link)`...`
const callExpr = unwrapPath(tag, t.isCallExpression);
if (callExpr) {
const callee = callExpr.get('callee');
if (isStyledImport(callee)) {
return true;
}
return false;
}
}
return false;
}
const REACT_FACTORY = [
'forwardRef',
'memo',
'createClass',
// 'lazy',
];
function isReactComponent(
expr: NodePath,
flags: Options['flags'],
): boolean {
// Check for class components
const classExpr = unwrapPath(expr, t.isClassExpression);
if (classExpr && isReactClassComponent(classExpr)) {
return true;
}
// Check for function components
const funcExpr = unwrapPath(expr, isValidFunction);
if (funcExpr && !funcExpr.node.generator && funcExpr.node.params.length < 3) {
return true;
}
// Time for call exprs
const callExpr = unwrapPath(expr, t.isCallExpression);
if (callExpr) {
const callee = callExpr.get('callee');
// React
const factory = [...REACT_FACTORY];
if (!flags?.noCreateContext) {
factory.push('createContext');
}
if (
(callee.isExpression() &&
isNamespaceExport('React', REACT_FACTORY, callee)) ||
pathReferencesImport(callee, 'react', REACT_FACTORY, false, true)
) {
return true;
}
const identifier = unwrapPath(callee, t.isIdentifier);
if (identifier) {
if (identifier.node.name === 'createReactClass') {
return true;
}
// Assume HOCs
if (/^with[A-Z]/.test(identifier.node.name)) {
return true;
}
}
}
if (flags?.noStyledComponents) return false;
if (isStyledComponent('@emotion/styled', ['default'], expr)) {
return true;
}
if (isStyledComponent('styled-components', ['default'], expr)) {
return true;
}
return false;
}
export const reactScanComponentNamePlugin = (options?: Options): PluginObj => ({
name: 'react-scan/component-name',
visitor: {
Program(path) {
const assignedNames = getAssignedDisplayNames(path);
path.traverse({
ClassDeclaration(path) {
if (isReactClassComponent(path)) {
if (!path.node.id) {
return;
}
const name = path.node.id.name;
if (assignedNames.has(name)) {
return;
}
assignDisplayName(path, name, options?.flags?.noTryCatchDisplayNames);
}
},
FunctionDeclaration(path) {
const decl = path.node;
if (
// Check if the declaration has an identifier, and then check
decl.id &&
// if the name is component-ish
isComponentishName(decl.id.name, options?.flags) &&
!decl.generator &&
// Might be component-like, but the only valid components
// have zero, one or two (forwardRef) parameters
decl.params.length < 3
) {
if (!path.node.id) {
return;
}
const name = path.node.id.name;
if (assignedNames.has(name)) {
return;
}
assignDisplayName(path, name, options?.flags?.noTryCatchDisplayNames);
}
},
VariableDeclarator(path) {
if (!path.parentPath.isVariableDeclaration()) {
return;
}
const identifier = path.node.id;
const init = path.get('init');
if (!(init.isExpression() && t.isIdentifier(identifier))) {
return;
}
if (!isComponentishName(identifier.name, options?.flags)) {
return;
}
if (isReactComponent(init, options?.flags)) {
const name = identifier.name;
if (!assignedNames.has(name)) {
assignDisplayName(
path.parentPath,
name,
options?.flags?.noTryCatchDisplayNames,
);
}
}
},
});
},
},
});
================================================
FILE: packages/scan/src/react-component-name/babel/is-componentish-name.ts
================================================
// This is just a Pascal heuristic
// we only assume a function is a component
import type { Options } from '../core/options';
// if the first character is in uppercase
export function isComponentishName(name: string, flags: Options['flags']) {
return (
name[0] >= 'A' &&
name[0] <= 'Z' &&
!flags?.ignoreComponentSubstrings?.some((substring) =>
name.includes(substring),
)
);
}
================================================
FILE: packages/scan/src/react-component-name/babel/is-nested-expression.ts
================================================
import type * as t from '@babel/types';
type NestedExpression =
| t.ParenthesizedExpression
| t.TypeCastExpression
| t.TSAsExpression
| t.TSSatisfiesExpression
| t.TSNonNullExpression
| t.TSInstantiationExpression
| t.TSTypeAssertion;
export const isNestedExpression = (node: t.Node): node is NestedExpression => {
switch (node.type) {
case 'ParenthesizedExpression':
case 'TypeCastExpression':
case 'TSAsExpression':
case 'TSSatisfiesExpression':
case 'TSNonNullExpression':
case 'TSTypeAssertion':
case 'TSInstantiationExpression':
return true;
default:
return false;
}
};
================================================
FILE: packages/scan/src/react-component-name/babel/is-path-valid.ts
================================================
import type { NodePath } from '@babel/core';
import type * as t from '@babel/types';
type TypeFilter = (node: t.Node) => node is V;
export const isPathValid = (
path: unknown,
key: TypeFilter,
): path is NodePath => {
return key((path as NodePath).node);
};
================================================
FILE: packages/scan/src/react-component-name/babel/is-statement-top-level.ts
================================================
import type * as babel from '@babel/core';
import type * as t from '@babel/types';
export function isStatementTopLevel(
path: babel.NodePath,
): boolean {
let blockParent = path.scope.getBlockParent();
const programParent = path.scope.getProgramParent();
// a FunctionDeclaration binding refers to itself as the block parent
if (blockParent.path === path) {
blockParent = blockParent.parent;
}
return programParent === blockParent;
}
================================================
FILE: packages/scan/src/react-component-name/babel/path-references-import.ts
================================================
import type { NodePath } from '@babel/core';
import * as t from '@babel/types';
import { isPathValid } from './is-path-valid';
import { unwrapPath } from './unwrap';
export const pathReferencesImport = (
path: NodePath,
moduleSource: string,
importName: string[],
asType: boolean,
defaultNamespace = false,
): boolean => {
const identifier = unwrapPath(path, t.isIdentifier);
if (identifier) {
const binding = path.scope.getBinding(identifier.node.name);
if (binding && binding.kind === 'module') {
const importPath = binding.path;
const importParent = importPath.parentPath;
if (
isPathValid(importParent, t.isImportDeclaration) &&
importParent.node.source.value === moduleSource
) {
if (isPathValid(importPath, t.isImportSpecifier)) {
const key = t.isIdentifier(importPath.node.imported)
? importPath.node.imported.name
: importPath.node.imported.value;
return importName.includes(key);
}
if (isPathValid(importPath, t.isImportDefaultSpecifier)) {
return importName.includes('default');
}
if (isPathValid(importPath, t.isImportNamespaceSpecifier)) {
return importName.includes('*');
}
}
}
return false;
}
const memberExpr =
unwrapPath(path, t.isMemberExpression) ||
unwrapPath(path, t.isOptionalMemberExpression);
if (memberExpr) {
const object = unwrapPath(memberExpr.get('object'), t.isIdentifier);
if (!object) {
return false;
}
const property = memberExpr.get('property');
if (isPathValid(property, t.isIdentifier)) {
return (
importName.includes(property.node.name) &&
(pathReferencesImport(object, moduleSource, ['*'], asType) ||
(defaultNamespace &&
pathReferencesImport(object, moduleSource, ['default'], asType)))
);
}
if (isPathValid(property, t.isStringLiteral)) {
return (
importName.includes(property.node.value) &&
(pathReferencesImport(object, moduleSource, ['*'], asType) ||
(defaultNamespace &&
pathReferencesImport(object, moduleSource, ['default'], asType)))
);
}
}
return false;
};
================================================
FILE: packages/scan/src/react-component-name/babel/unwrap.ts
================================================
import type { NodePath } from '@babel/core';
import type * as t from '@babel/types';
import { isNestedExpression } from './is-nested-expression';
import { isPathValid } from './is-path-valid';
type TrueTypeFilter = (node: t.Node) => node is U;
type TypeCheck = K extends TrueTypeFilter