chore: import upstream snapshot with attribution
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Require review for workflow changes to prevent supply chain attacks
|
||||
.github/workflows/ @aidenybai
|
||||
.github/actions/ @aidenybai
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -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)
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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!
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`aidenybai/react-scan`
|
||||
- 原始仓库:https://github.com/aidenybai/react-scan
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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";
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
```
|
||||
@@ -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";
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
```
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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 />);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 725 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 631 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
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);
|
||||
}
|
||||
@@ -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 {}
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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/*"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 {}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.css?inline" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
src/web/assets/css/styles.css
|
||||
@@ -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
|
||||
@@ -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 <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> <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> <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)
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dist/auto';
|
||||
@@ -0,0 +1,2 @@
|
||||
#! /usr/bin/env node
|
||||
require('../dist/cli');
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "*.css" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||