commit da10c3c2c3c69075c695c6aa695dbd4d3caad9ac Author: wehub-resource-sync Date: Mon Jul 13 12:20:35 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -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) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..ab33b78 --- /dev/null +++ b/.changeset/config.json @@ -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"] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..879fa13 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Require review for workflow changes to prevent supply chain attacks +.github/workflows/ @aidenybai +.github/actions/ @aidenybai diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b3990cd --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -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 \ No newline at end of file diff --git a/.github/assets/airbnb-logo.png b/.github/assets/airbnb-logo.png new file mode 100644 index 0000000..d89104e Binary files /dev/null and b/.github/assets/airbnb-logo.png differ diff --git a/.github/assets/demo.gif b/.github/assets/demo.gif new file mode 100644 index 0000000..93f11d4 Binary files /dev/null and b/.github/assets/demo.gif differ diff --git a/.github/assets/faire-logo.svg b/.github/assets/faire-logo.svg new file mode 100644 index 0000000..7d0c693 --- /dev/null +++ b/.github/assets/faire-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/assets/github.mp4 b/.github/assets/github.mp4 new file mode 100644 index 0000000..569d555 Binary files /dev/null and b/.github/assets/github.mp4 differ diff --git a/.github/assets/instagram.mp4 b/.github/assets/instagram.mp4 new file mode 100644 index 0000000..4245747 Binary files /dev/null and b/.github/assets/instagram.mp4 differ diff --git a/.github/assets/logo.svg b/.github/assets/logo.svg new file mode 100644 index 0000000..5030b05 --- /dev/null +++ b/.github/assets/logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/.github/assets/perplexity-logo.png b/.github/assets/perplexity-logo.png new file mode 100644 index 0000000..4470d9c Binary files /dev/null and b/.github/assets/perplexity-logo.png differ diff --git a/.github/assets/shopify-logo.png b/.github/assets/shopify-logo.png new file mode 100755 index 0000000..54cdeb5 Binary files /dev/null and b/.github/assets/shopify-logo.png differ diff --git a/.github/assets/twitter.mp4 b/.github/assets/twitter.mp4 new file mode 100644 index 0000000..93b7573 Binary files /dev/null and b/.github/assets/twitter.mp4 differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml new file mode 100644 index 0000000..900a1b6 --- /dev/null +++ b/.github/workflows/build-extension.yml @@ -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) diff --git a/.github/workflows/pkg-pr-new.yaml b/.github/workflows/pkg-pr-new.yaml new file mode 100644 index 0000000..d16f59b --- /dev/null +++ b/.github/workflows/pkg-pr-new.yaml @@ -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 }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb567e7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..54036c8 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +prefer-workspace-packages=true +link-workspace-packages=true \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b1aecd9 --- /dev/null +++ b/.vscode/settings.json @@ -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" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..16fdc5c --- /dev/null +++ b/AGENTS.md @@ -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 +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..775a3b7 --- /dev/null +++ b/CONTRIBUTING.md @@ -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! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..264dc4e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cced77e --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# 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) +React Scan in action + +## Install + +The `init` command will automatically detect your framework, install `react-scan` via npm, and set up your project. + +```bash +npx -y react-scan@latest init +``` + +### Manual Installation + +Install the package: + +```bash +npm install -D react-scan +``` + +Then add the script tag to your app. Pick the guide for your framework: + +#### Script Tag + +Paste this before any scripts in your `index.html`: + +```html + + +``` + +#### Next.js (App Router) + +Add this inside of your `app/layout.tsx`: + +```tsx +import Script from "next/script"; + +export default function RootLayout({ children }) { + return ( + + + + + +
+ + + +``` + +#### Remix + +Add this inside your `app/root.tsx`: + +```tsx +import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + +``` + +If you want react-scan to also run in production, use the react-scan/all-environments import path +```diff +- import { scan } from "react-scan"; ++ import { scan } from "react-scan/all-environments"; +``` diff --git a/docs/installation/cdn.md b/docs/installation/cdn.md new file mode 100644 index 0000000..3991216 --- /dev/null +++ b/docs/installation/cdn.md @@ -0,0 +1,23 @@ +# CDN + +You can choose one of the following URLs to initialize React Scan via CDN. + +## Usage + +```html + +``` + +## Available URLs + +### JSDelivr + +```txt +https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js +``` + +### UNPKG + +```txt +https://unpkg.com/react-scan/dist/auto.global.js +``` diff --git a/docs/installation/create-react-app.md b/docs/installation/create-react-app.md new file mode 100644 index 0000000..73a6cfd --- /dev/null +++ b/docs/installation/create-react-app.md @@ -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 + + + + + + + + + + + +``` + +## 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. diff --git a/docs/installation/next-js-app-router.md b/docs/installation/next-js-app-router.md new file mode 100644 index 0000000..13878dc --- /dev/null +++ b/docs/installation/next-js-app-router.md @@ -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 ( + + + + + + + + + + +``` diff --git a/docs/installation/react-router.md b/docs/installation/react-router.md new file mode 100644 index 0000000..b9ad737 --- /dev/null +++ b/docs/installation/react-router.md @@ -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 ( + + + + + + + + + + +``` + +## 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. diff --git a/docs/installation/tanstack-start.md b/docs/installation/tanstack-start.md new file mode 100644 index 0000000..1c35333 --- /dev/null +++ b/docs/installation/tanstack-start.md @@ -0,0 +1,99 @@ +# TanStack Router Guide + +## As a script tag + +Add the script tag to your `` component at `app/routes/__root`. + +Refer to the [CDN Guide](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) for the available URLs. + +```jsx +// app/routes/__root +import { Meta, Scripts } from "@tanstack/start"; +// ... + +function RootDocument({ children }) { + return ( + + + + + + + + + + +``` + +## 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 diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..5226ef0 --- /dev/null +++ b/e2e/helpers.ts @@ -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 { + await page.goto(FIXTURE_URL); + await page.waitForSelector('[data-testid="heading"]', { timeout: 10_000 }); + // Wait for React Scan to boot and expose __REACT_SCAN__ + await page.waitForFunction( + () => typeof (window as any).__REACT_SCAN__?.ReactScanInternals !== 'undefined', + { timeout: 15_000 }, + ); + // Install a render counter by patching the onRender option on the signal + await page.evaluate(() => { + (window as any).__E2E_RENDER_COUNT__ = 0; + const internals = (window as any).__REACT_SCAN__?.ReactScanInternals; + if (internals?.options) { + const prev = internals.options.value; + const prevOnRender = prev.onRender; + internals.options.value = { + ...prev, + onRender: (...args: any[]) => { + (window as any).__E2E_RENDER_COUNT__++; + if (prevOnRender) prevOnRender(...args); + }, + }; + } + }); + // Wait for initial mount renders to settle then reset + await page.waitForTimeout(500); + await page.evaluate(() => { + (window as any).__E2E_RENDER_COUNT__ = 0; + }); +} + +export async function getRenderCount(page: Page): Promise { + return page.evaluate(() => (window as any).__E2E_RENDER_COUNT__ ?? 0); +} + +export async function waitForRenders( + page: Page, + timeout = 5000, +): Promise { + const startCount = await getRenderCount(page); + return page.evaluate( + ({ start, t }) => { + return new Promise((resolve) => { + const check = () => { + const current = (window as any).__E2E_RENDER_COUNT__ ?? 0; + if (current > start) { + resolve(current - start); + return true; + } + return false; + }; + if (check()) return; + const interval = setInterval(() => { + if (check()) clearInterval(interval); + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(0); + }, t); + }); + }, + { start: startCount, t: timeout }, + ); +} + +export async function isReactScanActive(page: Page): Promise { + return page.evaluate(() => { + return typeof (window as any).__REACT_SCAN__ !== 'undefined'; + }); +} + +export async function hasShadowRoot(page: Page): Promise { + return page.evaluate(() => { + return document.getElementById('react-scan-root')?.shadowRoot != null; + }); +} + +export async function getInspectStateKind( + page: Page, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await page.evaluate(() => { + const active = document.activeElement as HTMLElement | null; + active?.blur(); + }); +} + +export async function enterInspectMode(page: Page): Promise { + await waitForToolbarReady(page); + await inspectButton(page).click(); + await waitForInspectStateKind(page, 'inspecting'); +} + +export async function focusComponent( + page: Page, + targetSelector: string, +): Promise { + // 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'); +} diff --git a/e2e/inspector.spec.ts b/e2e/inspector.spec.ts new file mode 100644 index 0000000..15c25d7 --- /dev/null +++ b/e2e/inspector.spec.ts @@ -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); + }); +}); diff --git a/e2e/notifications.spec.ts b/e2e/notifications.spec.ts new file mode 100644 index 0000000..d0d2806 --- /dev/null +++ b/e2e/notifications.spec.ts @@ -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); + }); +}); diff --git a/e2e/outlines.spec.ts b/e2e/outlines.spec.ts new file mode 100644 index 0000000..26135ea --- /dev/null +++ b/e2e/outlines.spec.ts @@ -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 { + 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); + }); +}); diff --git a/e2e/overlay-inspector.spec.ts b/e2e/overlay-inspector.spec.ts new file mode 100644 index 0000000..2db4f65 --- /dev/null +++ b/e2e/overlay-inspector.spec.ts @@ -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'); + }); +}); diff --git a/e2e/overlay-notifications.spec.ts b/e2e/overlay-notifications.spec.ts new file mode 100644 index 0000000..9975d77 --- /dev/null +++ b/e2e/overlay-notifications.spec.ts @@ -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 => { + 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); + }); +}); diff --git a/e2e/overlay-toolbar.spec.ts b/e2e/overlay-toolbar.spec.ts new file mode 100644 index 0000000..f2411a0 --- /dev/null +++ b/e2e/overlay-toolbar.spec.ts @@ -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(); + }); +}); diff --git a/e2e/overlay-widget.spec.ts b/e2e/overlay-widget.spec.ts new file mode 100644 index 0000000..570480b --- /dev/null +++ b/e2e/overlay-widget.spec.ts @@ -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); + }); +}); diff --git a/e2e/toolbar.spec.ts b/e2e/toolbar.spec.ts new file mode 100644 index 0000000..34e147b --- /dev/null +++ b/e2e/toolbar.spec.ts @@ -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); + }); +}); diff --git a/kitchen-sink/index.html b/kitchen-sink/index.html new file mode 100644 index 0000000..9a37ad6 --- /dev/null +++ b/kitchen-sink/index.html @@ -0,0 +1,59 @@ + + + + + + + React Scan + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/kitchen-sink/package.json b/kitchen-sink/package.json new file mode 100644 index 0000000..7a29fa1 --- /dev/null +++ b/kitchen-sink/package.json @@ -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" + } +} diff --git a/kitchen-sink/postcss.config.mjs b/kitchen-sink/postcss.config.mjs new file mode 100644 index 0000000..51a6e4e --- /dev/null +++ b/kitchen-sink/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/kitchen-sink/src/examples/e2e-fixture/index.tsx b/kitchen-sink/src/examples/e2e-fixture/index.tsx new file mode 100644 index 0000000..8d976ea --- /dev/null +++ b/kitchen-sink/src/examples/e2e-fixture/index.tsx @@ -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 ( +
+ {count} + +
+ ); +} + +function UnstableProps(): JSX.Element { + const [tick, setTick] = useState(0); + return ( +
+ + {}} label="unstable" /> +
+ ); +} + +const MemoChild = memo(function MemoChild({ + style, + onClick, + label, +}: { + style: { color: string }; + onClick: () => void; + label: string; +}): JSX.Element { + return ( +
+ MemoChild: {label} +
+ ); +}); + +function ContextConsumer(): JSX.Element { + const theme = useContext(ThemeContext); + return
Theme: {theme}
; +} + +function ThemeToggle(): JSX.Element { + const [theme, setTheme] = useState('light'); + return ( + +
+ + +
+
+ ); +} + +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 ( +
+ + {rendering ? 'Rendering...' : 'Idle'} +
+ ); +} + +function RapidUpdater(): JSX.Element { + const [count, setCount] = useState(0); + + const triggerRapid = () => { + for (let i = 0; i < 50; i++) { + setTimeout(() => setCount((c) => c + 1), i * 16); + } + }; + + return ( +
+ + {count} +
+ ); +} + +export default function E2EFixture(): JSX.Element { + return ( +
+

React Scan E2E Fixture

+
+
+

Counter

+ +
+
+
+

Unstable Props (memo bypass)

+ +
+
+
+

Context

+ +
+
+
+

Slow Render

+ +
+
+
+

Rapid Updates

+ +
+
+ ); +} diff --git a/kitchen-sink/src/examples/sierpinski/index.tsx b/kitchen-sink/src/examples/sierpinski/index.tsx new file mode 100644 index 0000000..b0fe924 --- /dev/null +++ b/kitchen-sink/src/examples/sierpinski/index.tsx @@ -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 ( +
+ +
+ ); +}; + +interface SlowTriangleProps { + x: number; + y: number; + s: number; + seconds: number; +} + +const SlowTriangle = ({ x, y, s, seconds }: SlowTriangleProps) => { + s = s / 2; + + const slow = useMemo(() => { + const e = performance.now() + 0.8; + // Artificially long execution time. + while (performance.now() < e) {} + return seconds; + }, [seconds]); + + return ( + <> + + + + + ); +}; + +interface TriangleProps { + + x: number; + y: number; + s: number; + seconds: number; +} + +const Triangle = ({ x, y, s, seconds }: TriangleProps) => { + if (s <= TARGET) { + return ( + + ); + } + return ; +}; + +interface DotProps { + x: number; + y: number; + s: number; + text: number; +} + +const Dot = ({ x, y, s, text }: DotProps) => { + const [hover, setHover] = useState(false); + const onEnter = () => setHover(true); + const onExit = () => setHover(false); + + return ( +
+ {hover ? '**' + text + '**' : text} +
+ ); +}; + +export default function App(): JSX.Element { + return ( + <> + {/* */} + + + ); +} diff --git a/kitchen-sink/src/examples/sierpinski/styles.css b/kitchen-sink/src/examples/sierpinski/styles.css new file mode 100644 index 0000000..c5aa1db --- /dev/null +++ b/kitchen-sink/src/examples/sierpinski/styles.css @@ -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; +} \ No newline at end of file diff --git a/kitchen-sink/src/examples/todo-list/index.tsx b/kitchen-sink/src/examples/todo-list/index.tsx new file mode 100644 index 0000000..5b0470d --- /dev/null +++ b/kitchen-sink/src/examples/todo-list/index.tsx @@ -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 ( +
+
{item.message}
+
+ + +
+
+ ); +} + +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 ( +
{ + e.preventDefault(); + + setList(list => [ + ...list, + { + done: false, + message, + id: index, + }, + ]); + setIndex(index + 1); + setMessage(''); + }} + > + { + setMessage((e.target as HTMLInputElement).value); + }} + /> + +
+ ); +} + +function TodoList(): JSX.Element { + const [list, setList] = useState([]); + const [index, setIndex] = useState(0); + return ( + <> + +
+ {list.map(item => ( + + ))} +
+ + ); +} + +export default function App(): JSX.Element { + return ( +
+

Todo List

+ +
+ ); +} diff --git a/kitchen-sink/src/examples/todo-list/styles.css b/kitchen-sink/src/examples/todo-list/styles.css new file mode 100644 index 0000000..3610cbd --- /dev/null +++ b/kitchen-sink/src/examples/todo-list/styles.css @@ -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); +} diff --git a/kitchen-sink/src/index.css b/kitchen-sink/src/index.css new file mode 100644 index 0000000..293d3b1 --- /dev/null +++ b/kitchen-sink/src/index.css @@ -0,0 +1,3 @@ +body { + margin: 0; +} diff --git a/kitchen-sink/src/index.tsx b/kitchen-sink/src/index.tsx new file mode 100644 index 0000000..bff1469 --- /dev/null +++ b/kitchen-sink/src/index.tsx @@ -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 }>( + './examples/**/index.tsx', + { + eager: false, + }, +); + +const root = document.getElementById('root'); + +if (root) { + const embedded = new URLSearchParams(window.location.search); + const page = embedded.get('example'); + const target = `./examples/${page}/index.tsx`; + if (page && target in examples) { + const App = lazy(examples[target]); + createRoot(root).render(); + } else { + createRoot(root).render(); + } +} diff --git a/kitchen-sink/src/main.css b/kitchen-sink/src/main.css new file mode 100644 index 0000000..1bd979f --- /dev/null +++ b/kitchen-sink/src/main.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + overflow: hidden; +} diff --git a/kitchen-sink/src/main.tsx b/kitchen-sink/src/main.tsx new file mode 100644 index 0000000..8e05e83 --- /dev/null +++ b/kitchen-sink/src/main.tsx @@ -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 ( +
+
+

react-scan

+
+
+ {/* content */} +
+ {/* sidebar */} + {examples.map((item, index) => ( + + ))} +
+
+ {/* iframe */} +