chore: import upstream snapshot with attribution
Publish Any Commit / build (22) (push) Failing after 1s
Build Extension / build (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:20:35 +08:00
commit da10c3c2c3
277 changed files with 41763 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# 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)
+11
View File
@@ -0,0 +1,11 @@
{
"$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"]
}
+3
View File
@@ -0,0 +1,3 @@
# Require review for workflow changes to prevent supply chain attacks
.github/workflows/ @aidenybai
.github/actions/ @aidenybai
+74
View File
@@ -0,0 +1,74 @@
# 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
<svg width="844" height="844" viewBox="0 0 844 844" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" width="841" height="842" rx="115" fill="#7A68E7" fill-opacity="0.1"/>
<g clip-path="url(#clip0_1_46)">
<path d="M422.637 359.088C388.411 359.088 360.666 386.777 360.666 420.933C360.666 455.089 388.411 482.777 422.637 482.777C456.863 482.777 484.608 455.089 484.608 420.933C484.608 386.777 456.863 359.088 422.637 359.088Z" fill="#7A68E7"/>
<path d="M744.954 421.494C744.954 372.941 699.901 330.357 628.167 302.333C629.125 296.022 629.992 289.779 630.609 283.65C637.684 213.885 620.84 163.532 583.204 141.842C541.095 117.577 481.617 135.189 421.477 183.218C361.337 135.189 301.859 117.577 259.75 141.842C222.114 163.532 205.27 213.885 212.346 283.65C212.962 289.779 213.806 296.044 214.788 302.378C208.808 304.657 202.942 307.072 197.305 309.601C133.262 338.332 98 378.113 98 421.494C98 470.047 143.054 512.63 214.788 540.654C213.806 546.966 212.962 553.208 212.346 559.337C205.27 629.102 222.114 679.455 259.75 701.146C272.69 708.468 287.352 712.212 302.225 711.991C337.875 711.991 379.505 693.262 421.477 659.77C463.427 693.262 505.08 711.991 540.776 711.991C555.648 712.209 570.309 708.465 583.25 701.146C620.886 679.455 637.73 629.102 630.654 559.337C630.038 553.208 629.171 546.966 628.212 540.654C699.946 512.676 745 470.024 745 421.494M540.502 161.755C550.064 161.525 559.517 163.837 567.89 168.454C594.023 183.514 605.754 224.366 600.048 280.528C599.66 284.356 599.204 288.229 598.656 292.126C568.828 283.158 538.285 276.763 507.362 273.01C488.616 248.135 467.771 224.909 445.054 203.587C480.727 176.063 514.209 161.755 540.479 161.755M528.223 483.011C516.679 503.011 504.11 522.403 490.564 541.11C467.603 543.465 444.536 544.636 421.454 544.619C398.38 544.635 375.321 543.464 352.368 541.11C338.858 522.403 326.326 503.01 314.823 483.011C303.28 463.066 292.79 442.532 283.395 421.494C292.79 400.455 303.28 379.922 314.823 359.977C326.308 340.057 338.801 320.734 352.254 302.082C375.249 299.669 398.355 298.468 421.477 298.482C444.551 298.468 467.61 299.64 490.564 301.991C504.062 320.672 516.601 340.025 528.131 359.977C539.665 379.926 550.155 400.46 559.559 421.494C550.155 442.528 539.665 463.061 528.131 483.011M575.262 460.227C582.505 479.944 588.452 500.113 593.064 520.604C573.05 526.833 552.647 531.742 531.988 535.3C539.84 523.483 547.448 511.187 554.812 498.413C562.093 485.79 568.917 473.054 575.353 460.318M378.135 573.988C392.332 574.853 406.824 575.355 421.5 575.355C436.175 575.355 450.76 574.853 464.979 573.988C451.497 590.107 436.973 605.329 421.5 619.556C406.06 605.332 391.574 590.109 378.135 573.988ZM310.989 535.255C290.323 531.713 269.913 526.819 249.89 520.604C254.484 500.146 260.407 480.007 267.624 460.318C273.969 473.054 280.77 485.79 288.165 498.413C295.56 511.035 303.206 523.475 310.989 535.3M267.624 382.647C260.433 363.03 254.525 342.968 249.936 322.588C269.909 316.367 290.264 311.443 310.875 307.847C303.069 319.626 295.423 331.838 288.051 344.575C280.679 357.311 273.946 369.888 267.51 382.647M464.842 268.977C450.646 268.111 436.153 267.61 421.363 267.61C406.695 267.61 392.24 268.066 377.998 268.977C391.437 252.855 405.923 237.633 421.363 223.409C436.842 237.629 451.366 252.852 464.842 268.977ZM554.766 344.575C547.371 331.785 539.726 319.482 531.829 307.664C552.542 311.222 572.998 316.139 593.064 322.383C588.46 342.839 582.536 362.976 575.33 382.67C568.985 369.933 562.161 357.197 554.789 344.597M242.998 280.551C237.223 224.411 249.023 183.537 275.133 168.476C283.509 163.866 292.959 161.555 302.521 161.778C328.791 161.778 362.273 176.086 397.946 203.609C375.214 224.947 354.353 248.188 335.592 273.078C304.678 276.849 274.138 283.221 244.299 292.126C243.774 288.229 243.294 284.379 242.929 280.551M209.949 337.648C213.464 336.129 217.047 334.61 220.699 333.091C227.878 363.327 237.613 392.9 249.799 421.494C237.59 450.143 227.847 479.778 220.676 510.078C162.522 486.269 128.812 453.277 128.812 421.494C128.812 391.373 158.482 360.82 209.949 337.648ZM275.133 674.511C249.023 659.451 237.223 618.576 242.998 562.436C243.363 558.608 243.842 554.758 244.367 550.839C274.195 559.804 304.738 566.199 335.661 569.955C354.412 594.839 375.258 618.079 397.969 639.423C348.237 677.769 302.795 690.414 275.201 674.511M600.025 562.436C605.731 618.599 594 659.451 567.867 674.511C540.296 690.46 494.832 677.769 445.122 639.423C467.826 618.079 488.664 594.838 507.408 569.955C538.331 566.201 568.874 559.806 598.701 550.839C599.249 554.758 599.706 558.608 600.094 562.436M622.415 510.033C615.224 479.75 605.474 450.131 593.27 421.494C605.468 392.84 615.211 363.206 622.392 332.909C680.432 356.718 714.234 389.71 714.234 421.494C714.234 453.277 680.524 486.269 622.369 510.078" fill="#7A68E7"/>
</g>
<path d="M21.5 199.5V110.5C21.5 86.8957 30.8768 64.2582 47.5675 47.5675C64.2582 30.8768 86.8957 21.5 110.5 21.5H199.5" stroke="#7A68E7" stroke-width="42.6667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M644.5 21.5H733.5C757.104 21.5 779.742 30.8768 796.432 47.5675C813.123 64.2582 822.5 86.8957 822.5 110.5V199.5" stroke="#7A68E7" stroke-width="42.6667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M822.5 644.5V733.5C822.5 757.105 813.123 779.742 796.432 796.433C779.742 813.123 757.104 822.5 733.5 822.5H644.5" stroke="#7A68E7" stroke-width="42.6667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M199.5 822.5H110.5C86.8957 822.5 64.2582 813.123 47.5675 796.433C30.8768 779.742 21.5 757.105 21.5 733.5V644.5" stroke="#7A68E7" stroke-width="42.6667" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<clipPath id="clip0_1_46">
<rect width="647" height="581" fill="white" transform="translate(98 131)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+56
View File
@@ -0,0 +1,56 @@
name: Build Extension
on:
push:
branches:
- main
paths:
- 'packages/extension/**'
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Setup PNPM
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- 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)
+48
View File
@@ -0,0 +1,48 @@
name: Publish Any Commit
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- 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: Install react-doctor
run: pnpm --filter react-scan exec react-doctor --check
- 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 }}
+15
View File
@@ -0,0 +1,15 @@
node_modules
.DS_Store
.env
dist
**/*.tgz
*.log
build
!packages/extension/build/
playgrounds
# Turbo cache
.turbo/
# Playwright
test-results/
playwright-report/
.cursor
+2
View File
@@ -0,0 +1,2 @@
prefer-workspace-packages=true
link-workspace-packages=true
+12
View File
@@ -0,0 +1,12 @@
{
"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"
}
+102
View File
@@ -0,0 +1,102 @@
## General Rules
- MUST: Use TypeScript interfaces over types.
- MUST: Keep all types in the global scope.
- MUST: Use arrow functions over function declarations.
- MUST: Default to NO comments. Only add a comment when the user explicitly asks, or when the "why" is truly non-obvious - browser quirks, platform bugs, performance tradeoffs, fragile internal patching, or counter-intuitive design decisions. Never add comments that restate what the code does or what a well-named function/variable already conveys. When in doubt, leave the comment out.
- If a hack is required (like a `setTimeout` that hides a race), prefix with `// HACK: reason for hack`.
- Do not delete descriptive comments >3 lines without confirming with the user.
- MUST: Use kebab-case for files.
- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names).
- Example: for `.map()`, use `innerNode` instead of `n`.
- 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(x)` over `!!x`.
## Preact + @preact/signals Rules
React Scan's overlay UI runs as a Preact app mounted inside a Shadow DOM (see [`packages/scan/src/core/index.ts`](packages/scan/src/core/index.ts)). Reactivity is driven by `@preact/signals`, not React state.
### Signals
- MUST: Read signals via `.value` inside JSX/effects: `signalWidget.value` not `signalWidget()`.
- MUST: Write signals atomically: `signalWidget.value = { ...signalWidget.value, dimensions: ... }`. One signal per logical slice.
- MUST: Use `useSignal(initial)` for component-local reactive state, `useComputed(() => ...)` for derived values, `useSignalEffect(() => ...)` for subscriptions/DOM imperative work.
- MUST: Wrap module-level signals exported from a tree-shaken bundle with `/* @__PURE__ */ signal(...)` so production builds can drop unused state.
- SHOULD: Persist user-facing state via `readLocalStorage`/`saveLocalStorage` from [`~web/utils/helpers`](packages/scan/src/web/utils/helpers.ts) and seed the signal with the persisted value.
- NEVER: Mirror one signal into another inside `useSignalEffect` - find the single source of truth (compute on read).
### Effects
Before reaching for `useEffect`/`useSignalEffect`, classify the work:
- MUST: Use `useComputed` when the result is pure derived state from other signals. If no external system is touched, it is not an effect.
- MUST: Use event handlers and direct action calls when work happens because the user clicked, dragged, or navigated. Do not watch a flag in an effect to trigger imperative logic.
- MUST: Use `useEffect(..., [])` (or `useSignalEffect` with no deps) for one-time mount/cleanup of subscriptions, timers, listeners, and `MutationObserver`s. Always return the cleanup.
- MUST: Keep each effect single-purpose - one effect, one external bridge. Split mixed-responsibility effects.
- NEVER: Use an effect just to copy one signal into another.
- NEVER: Use an effect as an event bus (watching a trigger signal to run a command). Call the action directly from the event source.
### Props
- MUST: Access props via `props.title`, not destructuring, when the prop is read inside an event handler that fires later or inside an effect.
- SHOULD: Destructure props at the top of the body for purely-rendered values (read once during render).
- NEVER: Destructure a prop that is itself a signal-driven slice if you intend to re-read it after async work.
### JSX & DOM
- MUST: Use `className` (we render via Preact's React-compat JSX in [`tsconfig.json`](tsconfig.json) `jsxImportSource: preact`).
- MUST: Combine static `className="btn"` with reactive `className={cn("btn", isActive.value && "active")}` via [`cn`](packages/scan/src/web/utils/helpers.ts).
- MUST: Read refs in `useEffect` or via the callback ref pattern - DOM refs are populated after render.
- MUST: Mount overlay UI under the existing shadow root from `initRootContainer()` in [`core/index.ts`](packages/scan/src/core/index.ts). Do not append directly to `document.body`.
- SHOULD: Use `style={{ "--css-var": value }}` for dynamic CSS variables; class toggles for boolean states.
- SHOULD: Type refs as `let element: HTMLElement | null = null` with a guard.
## Build & Toolchain
This is a pnpm 10 monorepo with `packages/*` (libraries, extension, website) and a top-level `kitchen-sink/` (Playwright target). The toolchain is [Vite+](https://viteplus.dev) (`vp lint`, `vp fmt`, `vp check`) and `turbo` for pipeline orchestration.
### Approved built dependencies
The root [`package.json`](package.json) declares `pnpm.onlyBuiltDependencies` for `@parcel/watcher`, `esbuild`, `sharp`, `spawn-sync`, `unrs-resolver`. Without this list, `pnpm install` skips their native build steps and downstream packages fail.
### Build before test
`pnpm build` must complete before `pnpm test`, `pnpm test:e2e`, or `pnpm lint`. After modifying source files, always rebuild before running tests. Turbo enforces this via `dependsOn: ["^build"]` in [`turbo.json`](turbo.json).
### Playwright
`pnpm test:e2e` runs Playwright against the `kitchen-sink` Vite dev server on port 5173 (auto-started by [`playwright.config.ts`](playwright.config.ts)). Chromium must be installed: `npx playwright install chromium --with-deps`.
### Key commands
| Task | Command |
| -------------- | -------------------------------------------- |
| Install | `pnpm install` |
| Build | `pnpm build` |
| Dev watch | `pnpm dev` (watches `react-scan` + kitchen-sink) |
| Unit tests | `pnpm test` |
| E2E tests | `pnpm test:e2e` |
| Lint | `pnpm lint` (oxlint via vite-plus) |
| Lint + fix | `pnpm lint:fix` |
| Format | `pnpm format` (oxfmt via vite-plus) |
| Format check | `pnpm format:check` |
| Typecheck | `pnpm typecheck` |
| Combined | `pnpm check` (lint + fmt check + typecheck) |
## Testing
Run checks always before committing with:
```bash
pnpm build
pnpm lint
pnpm format
pnpm typecheck
pnpm test:e2e
```
+105
View File
@@ -0,0 +1,105 @@
# Contributing to React Scan
Thanks for your interest in contributing to React Scan! This document covers the project layout, dev setup, and the workflow for getting your change merged.
## Project Structure
This is a pnpm 10 monorepo orchestrated by [turbo](https://turbo.build) and built on [Vite+](https://viteplus.dev) (oxlint, oxfmt, tsdown).
```
packages/
├── scan/ # Core React Scan library + CLI
├── extension/ # Browser extension (Chrome / Firefox / Brave)
├── vite-plugin-react-scan/ # Vite plugin wrapper
└── website/ # Marketing site (Next.js, react-scan.com)
kitchen-sink/ # Playwright target app (Vite, port 5173)
e2e/ # Playwright specs
docs/installation/ # Per-framework install guides
```
## Development Setup
### Prerequisites
- Node.js >= 22
- pnpm >= 10
### Setup
```bash
git clone https://github.com/aidenybai/react-scan.git
cd react-scan
pnpm install
pnpm build
```
### Dev workflow
```bash
pnpm dev # watches react-scan + kitchen-sink in parallel
pnpm --filter react-scan build:copy # build and copy IIFE to clipboard for ad-hoc testing
pnpm --filter @react-scan/extension dev # extension in Chrome
pnpm --filter @react-scan/website dev # marketing site
```
## Code Style
We use [Vite+](https://viteplus.dev) which bundles oxlint and oxfmt. See [`AGENTS.md`](AGENTS.md) for the full rule set.
```bash
pnpm lint # check
pnpm lint:fix # auto-fix
pnpm format # write
pnpm format:check # verify
pnpm check # lint + format:check + typecheck
pnpm typecheck
```
Highlights:
- TypeScript everywhere; avoid `any`.
- Interfaces over types; kebab-case filenames.
- Arrow functions over function declarations.
- No comments unless the "why" is non-obvious. Hacks must be prefixed with `// HACK: …`.
- Magic numbers in `constants.ts` with `_MS` / `_PX` unit suffixes.
- One utility per file under `utils/`.
## Tests
```bash
pnpm test # vitest in each package
pnpm test:e2e # Playwright against kitchen-sink (auto-started on :5173)
pnpm test:e2e:ui # Playwright UI mode
```
## Pull Requests
1. Fork and create a branch (`git checkout -b feat/your-feature`).
2. Make your changes; run `pnpm check` and `pnpm test:e2e` before pushing.
3. If your change affects a published package, add a changeset:
```bash
pnpm changeset
```
4. Open a PR against `main`. Tag [@aidenybai](https://github.com/aidenybai) for review.
### Commit Convention
We use conventional commits:
- `feat:` New features
- `fix:` Bug fixes
- `docs:` Documentation
- `chore:` Maintenance
- `refactor:` Code changes that neither fix bugs nor add features
- `test:` Tests
Example: `fix(scan): handle null fiber in flash overlay`
## Getting Help
- Check existing [issues](https://github.com/aidenybai/react-scan/issues)
- Open a new issue with a minimal repro
- Join our [Discord](https://discord.gg/KV3FhDq7FA)
Happy contributing!
+7
View File
@@ -0,0 +1,7 @@
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.
+240
View File
@@ -0,0 +1,240 @@
# <img src="https://github.com/aidenybai/react-scan/blob/main/.github/assets/logo.svg" width="30" height="30" align="center" /> 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)
<img
src="https://github.com/user-attachments/assets/c21b3afd-c7e8-458a-a760-9a027be7dc02"
alt="React Scan in action"
width="600"
/>
## 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
<!-- paste this BEFORE any scripts -->
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
></script>
```
#### Next.js (App Router)
Add this inside of your `app/layout.tsx`:
```tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
<html>
<head>
<Script
src="//unpkg.com/react-scan/dist/auto.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
</head>
<body>{children}</body>
</html>
);
}
```
#### 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 (
<Html lang="en">
<Head>
<Script
src="//unpkg.com/react-scan/dist/auto.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
```
#### Vite
Example `index.html` with React Scan enabled:
```html
<!doctype html>
<html lang="en">
<head>
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
#### Remix
Add this inside your `app/root.tsx`:
```tsx
import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
export default function App() {
return (
<html>
<head>
<Meta />
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>
<Links />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
```
### Browser Extension
Install the extension by following the guide [here](https://github.com/aidenybai/react-scan/blob/main/BROWSER_EXTENSION_GUIDE.md).
## API Reference
<details>
<summary><code>Options</code></summary>
<br />
```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<Render>) => void;
onCommitFinish?: () => void;
}
```
</details>
- `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
<ExpensiveComponent onClick={() => 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).
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`aidenybai/react-scan`
- 原始仓库:https://github.com/aidenybai/react-scan
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+50
View File
@@ -0,0 +1,50 @@
# 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
<!doctype html>
<html lang="en">
<head>
<script is:inline src="https://unpkg.com/react-scan/dist/auto.global.js" />
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</html>
```
## As a module import
Add the script to your root layout
```astro
<!doctype html>
<html lang="en">
<head>
<script>
import { scan } from 'react-scan';
scan({
enabled: true,
});
</script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</html>
```
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";
```
+23
View File
@@ -0,0 +1,23 @@
# CDN
You can choose one of the following URLs to initialize React Scan via CDN.
## Usage
```html
<script src="https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js"></script>
```
## 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
```
+45
View File
@@ -0,0 +1,45 @@
# 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
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</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.
+71
View File
@@ -0,0 +1,71 @@
# 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 (
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
{/* rest of your scripts go under */}
</head>
<body>{children}</body>
</html>
);
}
```
## As a module import
Create a `<ReactScan>` 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 `<ReactScan>` 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 (
<html lang="en">
<ReactScan />
<body>{children}</body>
</html>
);
}
```
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";
```
+57
View File
@@ -0,0 +1,57 @@
# 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 (
<Html lang="en">
<Head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
{/* rest of your scripts go under */}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
```
## 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 <Component {...pageProps} />;
}
```
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";
```
+21
View File
@@ -0,0 +1,21 @@
# 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
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</html>
```
+83
View File
@@ -0,0 +1,83 @@
# 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 (
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// ...
```
> [!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 (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// ...
```
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.
+123
View File
@@ -0,0 +1,123 @@
# Remix Guide
## As a script tag
Add the script tag to your `<Layout>` 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 (
<html lang="en">
<head>
{/* Must run before any of your scripts */}
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// ...
```
> [!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 (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
```
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,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
// });
```
> [!CAUTION]
> This only works for React 19
+65
View File
@@ -0,0 +1,65 @@
# 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
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js"></script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</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.
+99
View File
@@ -0,0 +1,99 @@
# TanStack Router Guide
## As a script tag
Add the script tag to your `<RootDocument>` 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 (
<html>
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
<Meta />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
// ...
```
> [!CAUTION]
> This only works for React 19
## As a module import
Add the following code to your `<RootDocument>` 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 (
<html>
<head>
<Meta />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
```
> [!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, <StartClient router={router} />);
```
> [!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";
```
+54
View File
@@ -0,0 +1,54 @@
# 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
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</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
+203
View File
@@ -0,0 +1,203 @@
import { type Locator, type Page } from '@playwright/test';
export const FIXTURE_URL = '/?example=e2e-fixture';
export const TOOLBAR_SELECTORS = {
root: '#react-scan-root',
widget: '#react-scan-toolbar',
inspectButton: '#react-scan-inspect-element',
notificationsButton: '#react-scan-notifications',
outlineToggle: '.react-scan-toggle input[type="checkbox"]',
closeButton: '.react-scan-close-button[title="Close"]',
inspectorPanel: '.react-scan-inspector',
overlayCanvas: 'canvas.react-scan-inspector-overlay',
} as const;
export type InspectStateKind =
| 'uninitialized'
| 'inspect-off'
| 'inspecting'
| 'focused';
export async function gotoFixture(page: Page): Promise<void> {
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<number> {
return page.evaluate(() => (window as any).__E2E_RENDER_COUNT__ ?? 0);
}
export async function waitForRenders(
page: Page,
timeout = 5000,
): Promise<number> {
const startCount = await getRenderCount(page);
return page.evaluate(
({ start, t }) => {
return new Promise<number>((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<boolean> {
return page.evaluate(() => {
return typeof (window as any).__REACT_SCAN__ !== 'undefined';
});
}
export async function hasShadowRoot(page: Page): Promise<boolean> {
return page.evaluate(() => {
return document.getElementById('react-scan-root')?.shadowRoot != null;
});
}
export async function getInspectStateKind(
page: Page,
): Promise<InspectStateKind | null> {
return page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
return scan?.ReactScanInternals?.Store?.inspectState?.value?.kind ?? null;
});
}
export async function waitForInspectStateKind(
page: Page,
expectedKind: InspectStateKind,
timeout = 5000,
): Promise<void> {
await page.waitForFunction(
(kind) => {
const scan = (window as any).__REACT_SCAN__;
return (
scan?.ReactScanInternals?.Store?.inspectState?.value?.kind === kind
);
},
expectedKind,
{ timeout },
);
}
export async function isOutlineInstrumentationPaused(
page: Page,
): Promise<boolean | null> {
return page.evaluate(() => {
const scan = (window as any).__REACT_SCAN__;
const instrumentation = scan?.ReactScanInternals?.instrumentation;
return instrumentation?.isPaused?.value ?? null;
});
}
// The overlay UI is mounted inside an open shadow root; Playwright CSS
// locators pierce open shadow roots automatically, so plain selectors work.
export function toolbarWidget(page: Page): Locator {
return page.locator(TOOLBAR_SELECTORS.widget);
}
export function inspectButton(page: Page): Locator {
return page.locator(TOOLBAR_SELECTORS.inspectButton);
}
export function notificationsButton(page: Page): Locator {
return page.locator(TOOLBAR_SELECTORS.notificationsButton);
}
export function outlineToggle(page: Page): Locator {
return page.locator(TOOLBAR_SELECTORS.outlineToggle);
}
export async function waitForToolbarReady(page: Page): Promise<void> {
await inspectButton(page).waitFor({ state: 'visible', timeout: 10_000 });
}
// When the widget is expanded, the absolutely-positioned resize handles sit on
// top of the toolbar controls and swallow coordinate-based pointer events.
// Dispatching the click directly on the control drives its handler regardless
// of layering, which is what we want when asserting control behavior.
export async function clickToolbarControl(locator: Locator): Promise<void> {
await locator.dispatchEvent('click');
}
// The overlay buttons live in the shadow root, so clicking them makes the
// shadow host the document's active element. Blurring restores focus to the
// page, matching the scenario the global Escape shortcut is meant for.
export async function blurOverlay(page: Page): Promise<void> {
await page.evaluate(() => {
const active = document.activeElement as HTMLElement | null;
active?.blur();
});
}
export async function enterInspectMode(page: Page): Promise<void> {
await waitForToolbarReady(page);
await inspectButton(page).click();
await waitForInspectStateKind(page, 'inspecting');
}
export async function focusComponent(
page: Page,
targetSelector: string,
): Promise<void> {
// While inspecting, the overlay event-catcher covers the page, so the
// standard element-targeted click fails Playwright actionability checks.
// Drive raw pointer coordinates instead: the overlay resolves the element
// under the cursor on pointermove (throttled at 32ms) and locks focus on
// click. Two nudged moves defeat the throttle's leading-edge gate.
const box = await page.locator(targetSelector).boundingBox();
if (!box) {
throw new Error(`Target ${targetSelector} has no bounding box`);
}
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
await page.mouse.move(centerX, centerY);
await page.waitForTimeout(40);
await page.mouse.move(centerX + 1, centerY + 1);
await page.waitForTimeout(40);
await page.mouse.click(centerX, centerY);
await waitForInspectStateKind(page, 'focused');
}
+47
View File
@@ -0,0 +1,47 @@
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);
});
});
+43
View File
@@ -0,0 +1,43 @@
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);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { test, expect, type Page } from '@playwright/test';
import { gotoFixture, getRenderCount } from './helpers';
async function clickAndCountRenders(
page: Page,
selector: string,
waitMs = 1000,
): Promise<number> {
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);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { test, expect } from '@playwright/test';
import {
gotoFixture,
enterInspectMode,
focusComponent,
inspectButton,
clickToolbarControl,
blurOverlay,
getInspectStateKind,
waitForInspectStateKind,
waitForToolbarReady,
TOOLBAR_SELECTORS,
} from './helpers';
const COMPONENT_TREE_SEARCH = 'input[placeholder="Component name, /regex/, or [type]"]';
test.describe('Overlay inspector flow', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
await waitForToolbarReady(page);
});
test('inspecting mode activates the overlay canvas', async ({ page }) => {
await enterInspectMode(page);
const overlay = page.locator(TOOLBAR_SELECTORS.overlayCanvas);
await expect(overlay).toHaveCount(1);
});
test('clicking a component while inspecting focuses it', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
expect(await getInspectStateKind(page)).toBe('focused');
});
test('focusing a component opens the inspector panel', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
await expect(page.locator(TOOLBAR_SELECTORS.inspectorPanel)).toBeVisible();
await expect(page.locator(COMPONENT_TREE_SEARCH)).toBeVisible();
});
test('inspector header shows the focused component name', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
await expect(
page.locator(`${TOOLBAR_SELECTORS.widget} >> text=Counter`).first(),
).toBeVisible();
});
test('a copy button appears when a component is focused', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
await expect(
page.locator('.react-scan-close-button[title*="Copy element"]'),
).toBeVisible();
});
test('close button exits inspection back to inspect-off', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
await clickToolbarControl(page.locator(TOOLBAR_SELECTORS.closeButton));
await waitForInspectStateKind(page, 'inspect-off');
expect(await getInspectStateKind(page)).toBe('inspect-off');
});
test('pressing Escape returns from focused to inspecting', async ({ page }) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
await blurOverlay(page);
await page.keyboard.press('Escape');
await waitForInspectStateKind(page, 'inspecting');
expect(await getInspectStateKind(page)).toBe('inspecting');
});
test('re-focusing a different component keeps the inspector open', async ({
page,
}) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
expect(await getInspectStateKind(page)).toBe('focused');
await blurOverlay(page);
await page.keyboard.press('Escape');
await waitForInspectStateKind(page, 'inspecting');
await focusComponent(page, '[data-testid="toggle-theme"]');
expect(await getInspectStateKind(page)).toBe('focused');
await expect(page.locator(TOOLBAR_SELECTORS.inspectorPanel)).toBeVisible();
});
test('toggling inspect button off while focused tears down the panel', async ({
page,
}) => {
await enterInspectMode(page);
await focusComponent(page, '[data-testid="increment"]');
// From "focused" the inspect button advances to "inspecting".
await clickToolbarControl(inspectButton(page));
await waitForInspectStateKind(page, 'inspecting');
expect(await getInspectStateKind(page)).toBe('inspecting');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { test, expect, type Page } from '@playwright/test';
import {
gotoFixture,
notificationsButton,
toolbarWidget,
getInspectStateKind,
waitForToolbarReady,
TOOLBAR_SELECTORS,
} from './helpers';
const historyHeader = (page: Page) =>
page.locator(`${TOOLBAR_SELECTORS.widget} >> text=History`).first();
const widgetHeight = async (page: Page): Promise<number> => {
const box = await toolbarWidget(page).boundingBox();
return box?.height ?? 0;
};
test.describe('Overlay notifications panel', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
await waitForToolbarReady(page);
});
test('clicking the notifications button opens the panel', async ({ page }) => {
await notificationsButton(page).click();
await expect(historyHeader(page)).toBeVisible();
});
test('panel renders the slowdown history chrome', async ({ page }) => {
await notificationsButton(page).click();
await expect(historyHeader(page)).toBeVisible();
// The "Clear all events" control is always present in the history panel,
// unlike the "No Events" empty state which disappears if React Scan
// incidentally records a slow render (e.g. under CPU contention).
await expect(
page.locator(
`${TOOLBAR_SELECTORS.widget} button[title="Clear all events"]`,
),
).toBeVisible();
});
test('clicking the notifications button again closes the panel', async ({
page,
}) => {
const minimizedHeight = await widgetHeight(page);
await notificationsButton(page).click();
await expect
.poll(async () => widgetHeight(page))
.toBeGreaterThan(minimizedHeight + 50);
await notificationsButton(page).click();
await expect
.poll(async () => widgetHeight(page))
.toBeLessThan(minimizedHeight + 50);
});
test('opening notifications turns inspection off', async ({ page }) => {
await notificationsButton(page).click();
await expect(historyHeader(page)).toBeVisible();
expect(await getInspectStateKind(page)).toBe('inspect-off');
});
test('a slow interaction is recorded and surfaced in the panel', async ({
page,
}) => {
await page.click('[data-testid="trigger-slow"]');
await page.waitForTimeout(1500);
await notificationsButton(page).click();
await expect(historyHeader(page)).toBeVisible();
await expect
.poll(
async () =>
page
.locator(`${TOOLBAR_SELECTORS.widget} >> text=No Events`)
.count(),
{ timeout: 10_000 },
)
.toBe(0);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from '@playwright/test';
import {
gotoFixture,
toolbarWidget,
inspectButton,
notificationsButton,
outlineToggle,
getInspectStateKind,
waitForInspectStateKind,
waitForToolbarReady,
isOutlineInstrumentationPaused,
TOOLBAR_SELECTORS,
} from './helpers';
test.describe('Overlay toolbar UI', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
await waitForToolbarReady(page);
});
test('renders the toolbar widget and its core controls', async ({ page }) => {
await expect(toolbarWidget(page)).toBeVisible();
await expect(inspectButton(page)).toBeVisible();
await expect(notificationsButton(page)).toBeVisible();
await expect(inspectButton(page)).toHaveAttribute('title', 'Inspect element');
await expect(notificationsButton(page)).toHaveAttribute(
'title',
'Notifications',
);
});
test('FPS meter is rendered in the toolbar', async ({ page }) => {
// The meter starts blank and only paints once its 200ms sampling interval
// ticks, which can lag under CPU contention, so poll the widget text.
await expect
.poll(
async () =>
toolbarWidget(page).evaluate((el) => el.textContent ?? ''),
{ timeout: 20_000 },
)
.toContain('FPS');
});
test('clicking inspect button enters inspecting mode', async ({ page }) => {
expect(await getInspectStateKind(page)).toBe('inspect-off');
await inspectButton(page).click();
await waitForInspectStateKind(page, 'inspecting');
expect(await getInspectStateKind(page)).toBe('inspecting');
});
test('inspect button icon color reflects inspecting state', async ({
page,
}) => {
const inactiveColor = await inspectButton(page).evaluate(
(el) => (el as HTMLElement).style.color,
);
expect(inactiveColor).toBe('rgb(153, 153, 153)');
await inspectButton(page).click();
await waitForInspectStateKind(page, 'inspecting');
await expect
.poll(async () =>
inspectButton(page).evaluate((el) => (el as HTMLElement).style.color),
)
.toBe('rgb(142, 97, 227)');
});
test('clicking inspect button twice toggles inspecting off', async ({
page,
}) => {
await inspectButton(page).click();
await waitForInspectStateKind(page, 'inspecting');
await inspectButton(page).click();
await waitForInspectStateKind(page, 'inspect-off');
expect(await getInspectStateKind(page)).toBe('inspect-off');
});
test('outline toggle starts enabled (instrumentation active)', async ({
page,
}) => {
await expect(outlineToggle(page)).toBeChecked();
expect(await isOutlineInstrumentationPaused(page)).toBe(false);
});
test('toggling outlines off pauses instrumentation and persists', async ({
page,
}) => {
await outlineToggle(page).click();
await expect
.poll(async () => isOutlineInstrumentationPaused(page))
.toBe(true);
await expect(outlineToggle(page)).not.toBeChecked();
const persistedEnabled = await page.evaluate(() => {
const raw = localStorage.getItem('react-scan-options');
return raw ? JSON.parse(raw).enabled : null;
});
expect(persistedEnabled).toBe(false);
});
test('toggling outlines off then on restores instrumentation', async ({
page,
}) => {
await outlineToggle(page).click();
await expect
.poll(async () => isOutlineInstrumentationPaused(page))
.toBe(true);
await outlineToggle(page).click();
await expect
.poll(async () => isOutlineInstrumentationPaused(page))
.toBe(false);
await expect(outlineToggle(page)).toBeChecked();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test';
import {
gotoFixture,
toolbarWidget,
notificationsButton,
isReactScanActive,
waitForToolbarReady,
TOOLBAR_SELECTORS,
} from './helpers';
const LOCALSTORAGE_WIDGET_KEY = 'react-scan-widget-settings-v2';
test.describe('Overlay widget container', () => {
test.beforeEach(async ({ page }) => {
await gotoFixture(page);
await waitForToolbarReady(page);
});
test('widget mounts with the expected identity attributes', async ({
page,
}) => {
const widget = toolbarWidget(page);
await expect(widget).toBeVisible();
await expect(widget).toHaveAttribute('dir', 'ltr');
});
test('widget fades in to full opacity', async ({ page }) => {
await expect
.poll(async () =>
toolbarWidget(page).evaluate((el) =>
Number(getComputedStyle(el).opacity),
),
)
.toBeGreaterThan(0.9);
});
test('all four resize handles are present in the DOM', async ({ page }) => {
const handles = page.locator(
`${TOOLBAR_SELECTORS.root} .resize-left, ${TOOLBAR_SELECTORS.root} .resize-right, ${TOOLBAR_SELECTORS.root} .resize-top, ${TOOLBAR_SELECTORS.root} .resize-bottom`,
);
await expect(handles).toHaveCount(4);
});
test('widget settings are persisted to localStorage', async ({ page }) => {
await expect
.poll(async () =>
page.evaluate(
(key) => localStorage.getItem(key) !== null,
LOCALSTORAGE_WIDGET_KEY,
),
)
.toBe(true);
const settings = await page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, LOCALSTORAGE_WIDGET_KEY);
expect(settings).not.toBeNull();
expect(settings.corner).toBeTruthy();
expect(settings.dimensions).toBeTruthy();
});
test('widget survives repeated host-app interactions', async ({ page }) => {
for (let clickIndex = 0; clickIndex < 5; clickIndex++) {
await page.click('[data-testid="increment"]');
}
await expect(toolbarWidget(page)).toBeVisible();
expect(await isReactScanActive(page)).toBe(true);
});
test('opening a panel expands the widget', async ({ page }) => {
const minimizedBox = await toolbarWidget(page).boundingBox();
expect(minimizedBox).not.toBeNull();
await notificationsButton(page).click();
await page.waitForTimeout(600);
await expect
.poll(async () => {
const box = await toolbarWidget(page).boundingBox();
return box?.width ?? 0;
})
.toBeGreaterThan(minimizedBox?.width ?? 0);
});
});
+70
View File
@@ -0,0 +1,70 @@
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);
});
});
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Scan</title>
<meta name="title" content="React Scan" />
<meta
name="description"
content="React Scan automatically detects and highlights components that cause performance issues in your React app. Drop it in anywhere script tag, npm, you name it!"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://react-scan.million.dev" />
<meta property="og:title" content="React Scan" />
<meta
property="og:description"
content="React Scan automatically detects and highlights components that cause performance issues in your React app. Drop it in anywhere script tag, npm, you name it!"
/>
<meta
property="og:image"
content="https://react-scan.million.dev/banner.png"
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://react-scan.million.dev" />
<meta property="twitter:title" content="React Scan" />
<meta
property="twitter:description"
content="React Scan automatically detects and highlights components that cause performance issues in your React app. Drop it in anywhere script tag, npm, you name it!"
/>
<meta
property="twitter:image"
content="https://react-scan.million.dev/banner.png"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<meta
name="keywords"
content="react, performance, debugging, developer tools, web development, javascript"
/>
<meta name="author" content="Aiden Bai" />
<meta name="theme-color" content="#8b5cf6" />
<link rel="canonical" href="https://react-scan.million.dev" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@react-scan/kitchen-sink",
"type": "module",
"private": true,
"publishConfig": {
"access": "restricted"
},
"scripts": {
"dev": "vp dev",
"build": "vp build",
"serve": "vp preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-scan": "workspace:*",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.13",
"typescript": "^6.0.3",
"vite": "^6.3.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
@@ -0,0 +1,147 @@
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 (
<div data-testid="counter">
<span data-testid="count">{count}</span>
<button data-testid="increment" type="button" onClick={() => setCount((c) => c + 1)}>
Increment
</button>
</div>
);
}
function UnstableProps(): JSX.Element {
const [tick, setTick] = useState(0);
return (
<div data-testid="unstable-section">
<button data-testid="trigger-unstable" type="button" onClick={() => setTick((t) => t + 1)}>
Trigger ({tick})
</button>
<MemoChild style={{ color: 'red' }} onClick={() => {}} label="unstable" />
</div>
);
}
const MemoChild = memo(function MemoChild({
style,
onClick,
label,
}: {
style: { color: string };
onClick: () => void;
label: string;
}): JSX.Element {
return (
<div data-testid="memo-child" style={style} onClick={onClick}>
MemoChild: {label}
</div>
);
});
function ContextConsumer(): JSX.Element {
const theme = useContext(ThemeContext);
return <div data-testid="context-value">Theme: {theme}</div>;
}
function ThemeToggle(): JSX.Element {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<div data-testid="theme-section">
<button
data-testid="toggle-theme"
type="button"
onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}
>
Toggle Theme
</button>
<ContextConsumer />
</div>
</ThemeContext.Provider>
);
}
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 (
<div data-testid="slow-section">
<button data-testid="trigger-slow" type="button" onClick={triggerSlowRender}>
Trigger Slow Render
</button>
<span data-testid="slow-status">{rendering ? 'Rendering...' : 'Idle'}</span>
</div>
);
}
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 (
<div data-testid="rapid-section">
<button data-testid="trigger-rapid" type="button" onClick={triggerRapid}>
Rapid Updates
</button>
<span data-testid="rapid-count">{count}</span>
</div>
);
}
export default function E2EFixture(): JSX.Element {
return (
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<h1 data-testid="heading">React Scan E2E Fixture</h1>
<hr />
<section>
<h2>Counter</h2>
<Counter />
</section>
<hr />
<section>
<h2>Unstable Props (memo bypass)</h2>
<UnstableProps />
</section>
<hr />
<section>
<h2>Context</h2>
<ThemeToggle />
</section>
<hr />
<section>
<h2>Slow Render</h2>
<SlowComponent />
</section>
<hr />
<section>
<h2>Rapid Updates</h2>
<RapidUpdater />
</section>
</div>
);
}
@@ -0,0 +1,138 @@
/**
* 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 (
<div
className="container"
style={{
transform: 'scaleX(' + scale / 2.1 + ') scaleY(0.7) translateZ(0.1px)',
}}
>
<Triangle x={0} y={0} s={1000} seconds={seconds} />
</div>
);
};
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 (
<>
<Triangle x={x} y={y - s / 2} s={s} seconds={slow} />
<Triangle x={x - s} y={y + s / 2} s={s} seconds={slow} />
<Triangle x={x + s} y={y + s / 2} s={s} seconds={slow} />
</>
);
};
interface TriangleProps {
x: number;
y: number;
s: number;
seconds: number;
}
const Triangle = ({ x, y, s, seconds }: TriangleProps) => {
if (s <= TARGET) {
return (
<Dot x={x - TARGET / 2} y={y - TARGET / 2} s={TARGET} text={seconds} />
);
}
return <SlowTriangle x={x} y={y} s={s} seconds={seconds} />;
};
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 (
<div
className="dot"
style={{
width: s + 'px',
height: s + 'px',
left: x + 'px',
top: y + 'px',
borderRadius: s / 2 + 'px',
lineHeight: s + 'px',
background: hover ? '#ff0' : '#61dafb',
}}
onMouseEnter={onEnter}
onMouseLeave={onExit}
>
{hover ? '**' + text + '**' : text}
</div>
);
};
export default function App(): JSX.Element {
return (
<>
{/* <Analytics /> */}
<TriangleDemo />
</>
);
}
@@ -0,0 +1,79 @@
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;
}
@@ -0,0 +1,128 @@
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 (
<div className={`todo-item ${item.done ? 'complete' : 'pending'}`}>
<div className="todo-item-content">{item.message}</div>
<div className="todo-item-actions">
<button
type="button"
className={`todo-item-toggle ${item.done ? 'complete' : 'pending'}`}
onClick={(): void => {
setList(list =>
list.map(value => {
if (value === item) {
return {
...value,
done: !item.done,
};
}
return value;
}),
);
}}
>
{item.done ? 'Completed' : 'Pending'}
</button>
<button
type="button"
className="todo-item-delete"
onClick={(): void => {
setList(list => list.filter(value => value.id !== item.id));
}}
>
Delete
</button>
</div>
</div>
);
}
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 (
<form
className="todo-list-form"
onSubmit={(e): void => {
e.preventDefault();
setList(list => [
...list,
{
done: false,
message,
id: index,
},
]);
setIndex(index + 1);
setMessage('');
}}
>
<input
type="text"
value={message}
onInput={(e): void => {
setMessage((e.target as HTMLInputElement).value);
}}
/>
<button type="submit" disabled={message === ''}>
Add
</button>
</form>
);
}
function TodoList(): JSX.Element {
const [list, setList] = useState<TodoItem[]>([]);
const [index, setIndex] = useState(0);
return (
<>
<TodoListForm setList={setList} index={index} setIndex={setIndex} />
<div className="todo-list">
{list.map(item => (
<TodoListItem key={item.id} item={item} setList={setList} />
))}
</div>
</>
);
}
export default function App(): JSX.Element {
return (
<div className="app">
<h1>Todo List</h1>
<TodoList />
</div>
);
}
@@ -0,0 +1,115 @@
* {
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);
}
+3
View File
@@ -0,0 +1,3 @@
body {
margin: 0;
}
+28
View File
@@ -0,0 +1,28 @@
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<false, string, { default: FC<unknown> }>(
'./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(<App />);
} else {
createRoot(root).render(<Home />);
}
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
overflow: hidden;
}
+44
View File
@@ -0,0 +1,44 @@
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 (
<div className="flex flex-col w-screen h-screen">
<div className="flex flex-none border-b border-gray-950">
<h1 className="m-8 font-bold text-3xl">react-scan</h1>
</div>
<div className="flex flex-1">
{/* content */}
<div className="flex flex-none flex-col border-r border-gray-950">
{/* sidebar */}
{examples.map((item, index) => (
<button
key={item.url}
className="px-8 py-4 border-b border-gray-950"
type="button"
onClick={() => setExample(index)}
>
{item.title}
</button>
))}
</div>
<div className="flex-1 flex items-center justify-center">
{/* iframe */}
<iframe className="flex-1 h-full" src={examples[example].url} />
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
};
+23
View File
@@ -0,0 +1,23 @@
{
"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"
}
}
+11
View File
@@ -0,0 +1,11 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
});
+47
View File
@@ -0,0 +1,47 @@
{
"private": true,
"type": "module",
"scripts": {
"build": "turbo run build --filter=react-scan --filter=@react-scan/extension --filter=@react-scan/vite-plugin-react-scan --filter=@react-scan/website",
"dev": "turbo run dev --filter=react-scan --filter=@react-scan/kitchen-sink",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"lint": "vp lint",
"lint:fix": "vp lint --fix",
"format": "vp fmt",
"format:check": "vp fmt --check",
"check": "vp check",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"changeset": "changeset add",
"version": "changeset version",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@playwright/test": "^1.59.1",
"@types/node": "^25.6.0",
"@voidzero-dev/vite-plus-core": "^0.1.12",
"turbo": "^2.9.3",
"typescript": "latest",
"vite-plus": "^0.1.12"
},
"engines": {
"node": ">=22",
"pnpm": ">=8"
},
"packageManager": "pnpm@10.24.0",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"sharp",
"spawn-sync",
"unrs-resolver"
],
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.12",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.12"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# Browser binary paths
# You only need to set these if the browsers are not in standard locations
# For macOS, use paths like:
BRAVE_BINARY="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
CHROME_BINARY="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
FIREFOX_BINARY="/Applications/Firefox.app/Contents/MacOS/firefox-bin"
# For Windows, use paths like:
# BRAVE_BINARY="C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
# CHROME_BINARY="C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# FIREFOX_BINARY="C:\\Program Files\\Mozilla Firefox\\firefox.exe"
# For Linux, use paths like:
# BRAVE_BINARY="/usr/bin/brave"
# CHROME_BINARY="/usr/bin/google-chrome"
# FIREFOX_BINARY="/usr/bin/firefox"
+28
View File
@@ -0,0 +1,28 @@
# 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.*
+69
View File
@@ -0,0 +1,69 @@
# @react-scan/extension
## 0.5.7
### Patch Changes
- fix
- Updated dependencies
- react-scan@0.5.7
## 0.5.6
### Patch Changes
- fix
- Updated dependencies
- react-scan@0.5.6
## 0.5.5
### Patch Changes
- fix
- Updated dependencies
- react-scan@0.5.5
## 0.5.4
### Patch Changes
- lite
- Updated dependencies
- react-scan@0.5.4
## 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
+71
View File
@@ -0,0 +1,71 @@
# 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.
<i>If you need to inspect the extension, open `chrome://extensions` in Chrome</i>
#### For Firefox
<br />
#### For Firefox
1. Run development server:
```bash
pnpm dev:firefox
```
2. This will automatically open Firefox with the extension loaded.
<i>If you need to inspect the extension, open `about:debugging#/runtime/this-firefox` in Firefox</i>
<br />
#### For Brave
1. Run development server:
```bash
pnpm dev:brave
```
2. This will automatically open Brave with the extension loaded.
<i>If you need to inspect the extension, open `brave://extensions` in Brave</i>
<br />
### 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.
Binary file not shown.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@react-scan/extension",
"version": "0.5.7",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist",
"build": "vp build",
"dev": "pnpm dev:chrome",
"dev:chrome": "cross-env BROWSER=chrome vp dev",
"dev:firefox": "cross-env BROWSER=firefox vp dev",
"dev:brave": "cross-env BROWSER=brave vp dev",
"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": "rm -rf build && pnpm pack:chrome && pnpm pack:firefox && pnpm pack:brave",
"lint": "vp lint",
"format": "vp fmt",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bippy": "^0.5.39",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-scan": "workspace:*",
"zod": "^4.4.2"
},
"devDependencies": {
"@types/chrome": "^0.1.40",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@types/webextension-polyfill": "^0.12.5",
"@vitejs/plugin-react": "^6.0.1",
"cross-env": "^10.1.0",
"semver": "^7.7.4",
"vite": "^6.3.0",
"vite-plugin-web-extension": "^4.5.1",
"vite-tsconfig-paths": "^6.1.1",
"webextension-polyfill": "^0.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,105 @@
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);
}
+59
View File
@@ -0,0 +1,59 @@
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<void> => {
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 {}
};
+110
View File
@@ -0,0 +1,110 @@
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<boolean> => {
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,
);
}
},
);
+56
View File
@@ -0,0 +1,56 @@
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<IEvents['react-scan:toggle-state']>(
'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<IEvents['react-scan:send-to-background']>(
'react-scan:send-to-background',
(event) => {
sendMessageToBackground(event.message);
},
);
+199
View File
@@ -0,0 +1,199 @@
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<reactScan.Options> => {
const storedOptions = readLocalStorage<reactScan.Options>(STORAGE_KEY);
let isEnabled = false;
try {
const storedEnabled = await storageGetItem<boolean>(
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<reactScan.Options>(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<IEvents['react-scan:send-to-background']>(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: false,
},
},
},
);
busSubscribe<IEvents['react-scan:toggle-state']>(
'react-scan:toggle-state',
async () => {
toggleNotification();
},
);
return;
}
if (isTargetPageAlreadyUsedReactScan()) {
createNotificationUI({
title: 'Already Initialized',
content: 'React Scan is already initialized on this page.',
});
busDispatch<IEvents['react-scan:send-to-background']>(
'react-scan:send-to-background',
{
topic: 'react-scan:send-to-background',
message: {
type: 'react-scan:is-enabled',
data: {
state: false,
},
},
},
);
busSubscribe<IEvents['react-scan:toggle-state']>(
'react-scan:toggle-state',
async () => {
toggleNotification();
},
);
return;
}
const storedOptions = readLocalStorage<reactScan.Options>(STORAGE_KEY);
if (storedOptions !== null) {
busDispatch<IEvents['react-scan:send-to-background']>(
'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<IEvents['react-scan:toggle-state']>(
'react-scan:toggle-state',
async () => {
if (!isReactAvailable || isTargetPageAlreadyUsedReactScan()) {
toggleNotification();
return;
}
try {
const isEnabled = await storageGetItem<boolean>(
EXTENSION_STORAGE_KEY,
'isEnabled',
);
await updateReactScanState(!!isEnabled);
} catch {
await updateReactScanState(null);
}
},
);
});
@@ -0,0 +1,132 @@
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<IEvents['react-scan:send-to-background']>(
'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);
};
@@ -0,0 +1,2 @@
// Bippy has a side-effect that installs the hook.
import 'bippy';
@@ -0,0 +1,53 @@
{
"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": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/inject/react-scan.ts"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": ["<all_urls>"],
"js": ["src/inject/index.ts"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": ["<all_urls>"],
"js": ["src/content/index.ts"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": ["icons/*"],
"matches": ["<all_urls>"]
}
]
}
@@ -0,0 +1,49 @@
{
"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", "<all_urls>"],
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/inject/react-scan.ts"],
"run_at": "document_start"
},
{
"matches": ["<all_urls>"],
"js": ["src/inject/index.ts"],
"run_at": "document_start"
},
{
"matches": ["<all_urls>"],
"js": ["src/content/index.ts"],
"run_at": "document_start"
}
],
"web_accessible_resources": ["icons/*"]
}
+37
View File
@@ -0,0 +1,37 @@
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<number, ReactRenderer>;
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;
}
}
+24
View File
@@ -0,0 +1,24 @@
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<typeof BroadcastSchema>;
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;
};
}
@@ -0,0 +1,2 @@
export const STORAGE_KEY = 'react-scan-options';
export const EXTENSION_STORAGE_KEY = 'react-scan-extension';
+215
View File
@@ -0,0 +1,215 @@
const isIframe = window !== window.top;
const isPopup = window.opener !== null;
export const canLoadReactScan = !isIframe && !isPopup;
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<Element, Element[]>();
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 = <T>(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 = <T>(storageKey: string, state: T): void => {
if (!IS_CLIENT) return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(state));
} catch {}
};
type EventCallback<T = unknown> = (data: T) => void;
const eventBus = new Map<string, Set<EventCallback>>();
export const busSubscribe = <T = unknown>(
event: string,
callback: EventCallback<T>,
): (() => 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 = <T = unknown>(event: string, data: T): void => {
const callbacks = eventBus.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
};
export const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const isStorageRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
export const storageGetItem = async <T>(storageKey: string, key: string): Promise<T | null> => {
try {
const result = await chrome.storage.local.get(storageKey);
const data = result[storageKey];
if (!isStorageRecord(data)) return null;
const value = data[key];
return value === undefined ? null : (value as T);
} catch {
return null;
}
};
export const storageSetItem = async <T>(
storageKey: string,
key: string,
value: T,
): Promise<void> => {
try {
const result = await chrome.storage.local.get(storageKey);
const data = result[storageKey];
const updatedData = {
...(isStorageRecord(data) ? data : {}),
[key]: value,
};
await chrome.storage.local.set({ [storageKey]: updatedData });
} catch {}
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*.css?inline" {
const content: string;
export default content;
}
+41
View File
@@ -0,0 +1,41 @@
{
"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" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+108
View File
@@ -0,0 +1,108 @@
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'],
},
};
});
+1
View File
@@ -0,0 +1 @@
src/web/assets/css/styles.css
+57
View File
@@ -0,0 +1,57 @@
# react-scan
## 0.5.7
### Patch Changes
- fix
## 0.5.6
### Patch Changes
- fix
## 0.5.5
### Patch Changes
- fix
## 0.5.4
### Patch Changes
- lite
## 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 <url>`) 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
+281
View File
@@ -0,0 +1,281 @@
# <img src="https://github.com/aidenybai/react-scan/blob/main/.github/assets/logo.svg" width="30" height="30" align="center" /> React Scan
React Scan automatically detects performance issues in your React app.
Previously, tools like:
- [`<Profiler />`](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&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://polaris.shopify.com/"><img src="https://raw.githubusercontent.com/aidenybai/react-scan/refs/heads/main/.github/assets/shopify-logo.png" height="30" align="center" /></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://www.faire.com/"><img src="https://raw.githubusercontent.com/aidenybai/react-scan/refs/heads/main/.github/assets/faire-logo.svg" height="20" align="center" /></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://perplexity.com/"><img src="https://raw.githubusercontent.com/aidenybai/react-scan/refs/heads/main/.github/assets/perplexity-logo.png" height="30" align="center" /></a>
### [**Try it out! →**](https://react-scan.million.dev)
![React Scan in action](https://raw.githubusercontent.com/aidenybai/react-scan/refs/heads/main/.github/assets/demo.gif)
## Install
### Package managers
```bash
npm i react-scan
```
```bash
pnpm add react-scan
```
```bash
yarn add react-scan
```
### CDN
```html
<!-- import this BEFORE any scripts -->
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
```
## 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)
## `react-scan/lite`: headless instrumentation
A tiny, UI-less entry point that taps into the same React DevTools profiling channel the Timeline Profiler uses. No toolbar, no canvas, no styles. Just a stream of commit / render / state-update events you can pipe to a callback or POST to an endpoint. Pairs naturally with a `long-animation-frame` `PerformanceObserver` so you can attribute a slow LoAF to a specific component subtree.
```ts
import { instrument } from "react-scan/lite";
const handle = instrument({
onEvent: (event) => console.log(event),
// optional: POST every event to an ingest endpoint
endpoint: "http://127.0.0.1:54321/ingest/abc123",
sessionId: "abc123",
// optional: enrich each fiber summary
recordChangeDescriptions: true, // why did this re-render? (props/state/context/hooks/parent)
includeFiberSource: true, // file:line of each fiber's JSX call site
includeFiberIdentity: true, // stable id across commits, for cascade histograms
});
handle.subscribe((event) => {
if (event.kind === "commit") {
// event.tree[] = per-fiber { name, fiberId, depth, actualDuration,
// source, ownerName, changeDescription }
// event.priorityName = "UserBlocking" | "Normal" | ...
}
if (event.kind === "profiling-hooks-status" && !event.available) {
// React 19.2+ prod or non-__PROFILE__ build: mark* hooks won't fire.
// Fall back to LoAF-only attribution.
}
});
handle.stop(); // idempotent
```
`instrument` must run before `react-dom` mounts (top of your entry, or as the first script in `<head>`). It's SSR-safe: calling `instrument()` in Node returns a noop handle whose `isActive()` is `false`.
### Per-fiber enrichment
| Option | Default | What you get |
| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `recordChangeDescriptions` | `false` | `changeDescription: { isFirstMount, props[], state, context, hooks[], parent }` per fiber. Answers "why this re-rendered" in a single event. |
| `includeFiberSource` | `false` | `source: { fileName, lineNumber, columnNumber }` + `ownerName`, read from `_debugSource` (R16/17/18) or `_debugStack` (R19+). Sync, no symbolication. |
| `includeFiberIdentity` | `false` | `fiberId: number`, stable monotonic id per logical fiber (alternate-aware), so the agent can build cross-commit histograms. |
| `includeLaneLabels` | `true` | `laneLabels: string[]` (translated via `renderer.getLaneLabelMap()`) and `priorityName` (e.g. `"UserBlocking"`) |
| `maxFibersPerCommit` | `5000` | Cap on tree size per commit to bound payload size |
| `minFiberActualDurationMs` | `0` | Skip fibers below this threshold |
### Build profile
`actualDuration` and the `mark*` profiling hooks only populate in `__PROFILE__` builds. In development that's the default; in production you can opt in by aliasing `react-dom` to `react-dom/profiling` in your bundler (`resolve.alias` in webpack/vite). Otherwise `onCommitFiberRoot` still fires but every fiber's `actualDuration` is `0`. The `profiling-hooks-status` event makes this explicit: `available: false` with `reason: "no-inject-method"` means React 19.2+ in prod or a non-`__PROFILE__` build, so the agent should fall back to LoAF-only attribution rather than concluding "idle".
### Coexistence with React DevTools
If React DevTools is also attached, `instrument()` logs a one-time warning: the `injectProfilingHooks` channel is a hard replace, so DevTools' Timeline Profiler stops receiving events while this instrumentation is active. Call `handle.stop()` to restore.
## API Reference
<details>
<summary><code>Options</code></summary>
<br />
```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<Render>) => void;
onCommitFinish?: () => void;
onPaintStart?: (outlines: Array<Outline>) => void;
onPaintFinish?: (outlines: Array<Outline>) => void;
}
```
</details>
- `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
<ExpensiveComponent onClick={() => 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.
### 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).
+1
View File
@@ -0,0 +1 @@
export * from './dist/auto';
+2
View File
@@ -0,0 +1,2 @@
#! /usr/bin/env node
require('../dist/cli');
+4
View File
@@ -0,0 +1,4 @@
declare module "*.css" {
const content: string;
export default content;
}
+260
View File
@@ -0,0 +1,260 @@
{
"name": "react-scan",
"version": "0.5.7",
"description": "Scan your React app for renders",
"keywords": [
"performance",
"react",
"react scan",
"react-scan",
"render"
],
"homepage": "https://react-scan.million.dev",
"bugs": {
"url": "https://github.com/aidenybai/react-scan/issues"
},
"license": "MIT",
"author": {
"name": "Aiden Bai",
"email": "aiden@million.dev",
"url": "https://million.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/aidenybai/react-scan.git"
},
"bin": "bin/cli.js",
"files": [
"dist",
"bin",
"package.json",
"README.md",
"LICENSE",
"auto.d.ts"
],
"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"
]
}
},
"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"
},
"./lite": {
"types": "./dist/lite/index.d.ts",
"import": "./dist/lite/index.mjs",
"require": "./dist/lite/index.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"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "pnpm build:css && NODE_ENV=production tsup",
"build:copy": "pnpm build && cat dist/auto.global.js | pbcopy",
"build:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css",
"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)/\"",
"pack": "npm version patch && pnpm build && npm pack",
"pack:bump": "node scripts/bump-version.mjs && pnpm run pack && echo $(pwd)/react-scan-$(node -p \"require('./package.json').version\").tgz | pbcopy",
"publint": "publint",
"test": "vp test run",
"test:watch": "vp test",
"lint": "vp lint",
"format": "vp fmt",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/types": "^7.29.0",
"@preact/signals": "^2.9.0",
"@rollup/pluginutils": "^5.3.0",
"bippy": "^0.5.39",
"commander": "^14.0.0",
"picocolors": "^1.1.1",
"preact": "^10.29.1",
"prompts": "^2.4.2",
"react-doctor": "latest",
"react-grab": "latest"
},
"devDependencies": {
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@remix-run/react": "*",
"@tailwindcss/postcss": "^4.2.4",
"@types/babel__core": "^7.20.5",
"@types/prompts": "^2.4.9",
"@types/react": "^19.2.14",
"autoprefixer": "^10.5.0",
"clsx": "^2.1.1",
"es-module-lexer": "^2.1.0",
"esbuild": "^0.28.0",
"next": "*",
"postcss": "^8.5.13",
"postcss-cli": "^11.0.0",
"publint": "^0.3.18",
"react": "*",
"react-dom": "*",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"terser": "^5.46.2",
"tsup": "^8.5.1",
"vitest": "^3.0.0"
},
"peerDependencies": {
"esbuild": ">=0.18.0",
"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"
},
"peerDependenciesMeta": {
"esbuild": {
"optional": true
}
},
"optionalDependencies": {
"unplugin": "^3.0.0"
}
}

Some files were not shown because too many files have changed in this diff Show More