From 28ebb213933c6bd7eecef17654d631479b79a4cb Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Mon, 16 Feb 2026 13:35:41 -0800 Subject: [PATCH 1/2] fix --- .changeset/remove-monitoring-cleanup.md | 12 + .gitignore | 4 + .oxlintrc.json | 31 + .vscode/settings.json | 20 +- AGENTS.md | 28 + README.md | 237 +- biome.json | 75 - e2e/helpers.ts | 80 + e2e/inspector.spec.ts | 47 + e2e/notifications.spec.ts | 43 + e2e/outlines.spec.ts | 68 + e2e/toolbar.spec.ts | 70 + .../src/examples/e2e-fixture/index.tsx | 147 + package.json | 11 +- packages/extension/package.json | 4 +- packages/extension/src/background/index.ts | 2 +- packages/extension/src/inject/index.ts | 16 +- packages/extension/vite.config.ts | 2 +- packages/scan/package.json | 89 +- packages/scan/scripts/bump-version.js | 4 +- packages/scan/src/cli-utils.mts | 487 +++ packages/scan/src/cli-utils.test.mts | 700 +++++ packages/scan/src/cli.mts | 429 +-- packages/scan/src/core/index.ts | 136 +- packages/scan/src/core/instrumentation.ts | 40 +- packages/scan/src/core/monitor/constants.ts | 58 - packages/scan/src/core/monitor/index.ts | 200 -- packages/scan/src/core/monitor/network.ts | 259 -- .../monitor/params/astro/Monitoring.astro | 12 - .../core/monitor/params/astro/component.ts | 22 - .../src/core/monitor/params/astro/index.ts | 3 - packages/scan/src/core/monitor/params/next.ts | 66 - .../core/monitor/params/react-router-v5.ts | 31 - .../core/monitor/params/react-router-v6.ts | 34 - .../scan/src/core/monitor/params/remix.ts | 32 - .../scan/src/core/monitor/params/types.ts | 4 - .../scan/src/core/monitor/params/utils.ts | 70 - packages/scan/src/core/monitor/performance.ts | 321 -- packages/scan/src/core/monitor/types.ts | 110 - packages/scan/src/core/monitor/utils.ts | 148 - .../src/core/notifications/event-tracking.ts | 6 +- .../src/core/notifications/outline-overlay.ts | 31 +- .../core/notifications/performance-utils.ts | 25 +- .../src/core/notifications/performance.ts | 182 +- packages/scan/src/core/utils.ts | 38 +- packages/scan/src/new-outlines/canvas.ts | 16 +- packages/scan/src/new-outlines/index.ts | 2 +- .../scan/src/react-component-name/astro.ts | 2 +- .../scan/src/react-component-name/index.ts | 2 +- .../src/web/assets/css/styles.tailwind.css | 38 +- .../scan/src/web/components/slider/index.tsx | 2 +- .../scan/src/web/hooks/use-delayed-value.ts | 2 +- packages/scan/src/web/utils/create-store.ts | 14 +- packages/scan/src/web/utils/geiger.ts | 86 - packages/scan/src/web/utils/helpers.ts | 9 - packages/scan/src/web/utils/lerp.ts | 3 - packages/scan/src/web/utils/log.ts | 12 +- packages/scan/src/web/utils/lru.ts | 121 - packages/scan/src/web/utils/outline.ts | 97 - packages/scan/src/web/utils/pin.ts | 107 - .../scan/src/web/utils/preact/use-constant.ts | 8 - .../scan/src/web/utils/preact/use-lazy-ref.ts | 15 - .../views/inspector/components-tree/index.tsx | 4 +- .../src/web/views/inspector/overlay/index.tsx | 4 +- .../scan/src/web/views/inspector/utils.ts | 14 +- .../src/web/views/inspector/what-changed.tsx | 27 +- .../whats-changed/use-change-store.ts | 12 +- .../views/notifications/collapsed-event.tsx | 2 +- .../scan/src/web/views/notifications/data.ts | 4 +- .../notifications/notification-header.tsx | 1 - .../web/views/notifications/notifications.tsx | 6 +- .../notifications/other-visualization.tsx | 4 +- .../src/web/views/notifications/popover.tsx | 17 +- .../views/notifications/slowdown-history.tsx | 2 +- packages/scan/src/web/views/toolbar/index.tsx | 2 +- packages/scan/src/web/widget/header.tsx | 91 - packages/scan/src/web/widget/index.tsx | 18 +- .../scan/src/web/widget/resize-handle.tsx | 8 +- packages/scan/tsup.config.ts | 14 +- packages/vite-plugin-react-scan/package.json | 4 +- packages/website/.eslintrc.json | 3 - packages/website/.oxlintrc.json | 8 + packages/website/AGENTS.md | 136 + packages/website/app/globals.css | 178 +- packages/website/app/layout.tsx | 126 +- .../app/monitoring/(components)/waitlist.tsx | 65 - packages/website/app/monitoring/page.tsx | 107 - packages/website/app/page.tsx | 209 +- packages/website/components/companies.tsx | 52 +- packages/website/components/footer.tsx | 25 +- packages/website/components/header.tsx | 62 +- .../website/components/icons/icon-discord.tsx | 17 + .../website/components/icons/icon-github.tsx | 21 + packages/website/components/icons/types.ts | 5 + packages/website/components/install-guide.tsx | 400 +-- packages/website/package.json | 7 +- playwright.config.ts | 36 + pnpm-lock.yaml | 2739 +++-------------- scripts/bump-version.js | 2 +- scripts/version-warning.mjs | 6 +- scripts/workspace.mjs | 2 +- 101 files changed, 3270 insertions(+), 6140 deletions(-) create mode 100644 .changeset/remove-monitoring-cleanup.md create mode 100644 .oxlintrc.json create mode 100644 AGENTS.md delete mode 100644 biome.json create mode 100644 e2e/helpers.ts create mode 100644 e2e/inspector.spec.ts create mode 100644 e2e/notifications.spec.ts create mode 100644 e2e/outlines.spec.ts create mode 100644 e2e/toolbar.spec.ts create mode 100644 kitchen-sink/src/examples/e2e-fixture/index.tsx create mode 100644 packages/scan/src/cli-utils.mts create mode 100644 packages/scan/src/cli-utils.test.mts delete mode 100644 packages/scan/src/core/monitor/constants.ts delete mode 100644 packages/scan/src/core/monitor/index.ts delete mode 100644 packages/scan/src/core/monitor/network.ts delete mode 100644 packages/scan/src/core/monitor/params/astro/Monitoring.astro delete mode 100644 packages/scan/src/core/monitor/params/astro/component.ts delete mode 100644 packages/scan/src/core/monitor/params/astro/index.ts delete mode 100644 packages/scan/src/core/monitor/params/next.ts delete mode 100644 packages/scan/src/core/monitor/params/react-router-v5.ts delete mode 100644 packages/scan/src/core/monitor/params/react-router-v6.ts delete mode 100644 packages/scan/src/core/monitor/params/remix.ts delete mode 100644 packages/scan/src/core/monitor/params/types.ts delete mode 100644 packages/scan/src/core/monitor/params/utils.ts delete mode 100644 packages/scan/src/core/monitor/performance.ts delete mode 100644 packages/scan/src/core/monitor/types.ts delete mode 100644 packages/scan/src/core/monitor/utils.ts delete mode 100644 packages/scan/src/web/utils/lerp.ts delete mode 100644 packages/scan/src/web/utils/lru.ts delete mode 100644 packages/scan/src/web/utils/outline.ts delete mode 100644 packages/scan/src/web/utils/preact/use-constant.ts delete mode 100644 packages/scan/src/web/utils/preact/use-lazy-ref.ts delete mode 100644 packages/website/.eslintrc.json create mode 100644 packages/website/.oxlintrc.json create mode 100644 packages/website/AGENTS.md delete mode 100644 packages/website/app/monitoring/(components)/waitlist.tsx delete mode 100644 packages/website/app/monitoring/page.tsx create mode 100644 packages/website/components/icons/icon-discord.tsx create mode 100644 packages/website/components/icons/icon-github.tsx create mode 100644 packages/website/components/icons/types.ts create mode 100644 playwright.config.ts diff --git a/.changeset/remove-monitoring-cleanup.md b/.changeset/remove-monitoring-cleanup.md new file mode 100644 index 00000000..504a7aa7 --- /dev/null +++ b/.changeset/remove-monitoring-cleanup.md @@ -0,0 +1,12 @@ +--- +"react-scan": minor +--- + +Remove monitoring module, replace Playwright CLI with interactive init command, clean up dead code + +- Removed the entire monitoring system (`packages/scan/src/core/monitor/`) and all related exports, types, and build entries +- Replaced the Playwright-based proxy CLI (`npx react-scan `) with an interactive `npx react-scan init` command that auto-detects your framework and sets up React Scan +- Removed unused code: old outline system, LRU cache, lazy refs, commented-out code blocks, and unused exports +- Consolidated duplicate utilities (safeGetValue, RenderPhase types) +- Simplified README to focus on the new init command +- Added CLI quick-start command to the website homepage diff --git a/.gitignore b/.gitignore index b599fe41..2db366dc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ playgrounds # SSL Certificates bin/certs/*.pem bin/certs/*.key +.cursor +# Playwright +test-results/ +playwright-report/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..b7dd7434 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "import"], + "ignorePatterns": [ + "dist", + "build", + "node_modules", + "**/*.css", + "**/*.astro" + ], + "categories": {}, + "rules": { + "no-unused-vars": [ + "warn", + { + "vars": "all", + "args": "all", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrors": "none" + } + ], + "no-unused-labels": "warn", + "no-unused-private-class-members": "warn", + "no-console": "warn", + "typescript/no-explicit-any": "warn", + "typescript/no-non-null-assertion": "warn", + "react/no-danger": "error", + "react-hooks/exhaustive-deps": "warn" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index a4309ac4..b1aecd9f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,28 +1,12 @@ { - "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.organizeImports.biome": "always", - "quickfix.biome": "always" - }, "css.lint.unknownAtRules": "ignore", - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - }, + "oxc.lint.enable": true, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "typescript.tsdk": "node_modules/typescript/lib", - "[css]": { - "editor.defaultFormatter": "biomejs.biome" - } + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ef479256 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +## General Rules + +- MUST: Use TypeScript interfaces over types. +- MUST: Keep all types in the global scope. +- MUST: Use arrow functions over function declarations +- MUST: Never comment unless absolutely necessary. + - If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack +- MUST: Use kebab-case for files +- MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names). + - Example: for .map(), you can use `innerX` instead of `x` + - Example: instead of `moved` use `didPositionChange` +- MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive. +- MUST: Do not type cast ("as") unless absolutely necessary +- MUST: Remove unused code and don't repeat yourself. +- MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution. +- MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`). +- MUST: Put small, focused utility functions in `utils/` with one utility per file. +- MUST: Use Boolean over !!. + +## Testing + +Run checks always before committing with: + +```bash +pnpm build +pnpm lint +pnpm format +``` diff --git a/README.md b/README.md index 6f245b97..92627b4b 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,16 @@ -> I'm working on something new (still free + open source!) -> -> React Grab allows you to select an element and copy its context (like HTML, React component, and file source) -> -> Check it out: [**react-grab.com**](https://react-grab.com) - # React Scan React Scan automatically detects performance issues in your React app. -Previously, tools like: - -- [React Devtools](https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html) can feel too complex and janky -- [Why Did You Render?](https://github.com/welldone-software/why-did-you-render) lacked simple visual cues - -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 -- No more having to use flame graphs when profiling +- Requires no code changes -- just drop it in +- Highlights exactly the components you need to optimize - Always accessible through a toolbar on page -### Try it in 5 seconds -
-npx react-scan airbnb.com
-
- -or on your local website -
-npx react-scan localhost:3000
-
- -> all installation options below - +### Quick Start +```bash +npx -y react-scan@latest init +``` ### [**Try out a demo! →**](https://react-scan.million.dev) ``` -## Usage +#### Framework guides - [Script Tag](https://github.com/aidenybai/react-scan/blob/main/docs/installation/cdn.md) - [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) - [Vite](https://github.com/aidenybai/react-scan/blob/main/docs/installation/vite.md) - [Create React App](https://github.com/aidenybai/react-scan/blob/main/docs/installation/create-react-app.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) - [Rsbuild](https://github.com/aidenybai/react-scan/blob/main/docs/installation/rsbuild.md) -### CLI - -If you want to run react scan on any URL (including localhost) from the cli, you can run: - -```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) - - -## After Setup - -
-How to use/feature descriptions - -### Toolbar -All react scan features are exposed through the toolbar that you will see in the bottom right corner of your page: - -image - -> You can drag this toolbar to any corner of the page - -### Render Outlines -By default, react scan will show outlines over components when they render. -> interact with your page to try it out! - -If you want to turn the outlines off, you can use the toggle in the toolbar to turn them off. This will persist across page loads and will only re-enable when you toggle it back on: - -Pasted image 20250629130910 - - -### Why did my component render -If you want to find out why a component re-rendered, you can click the icon at the very left of the toolbar, and then click on the component you want to inspect -Pasted image 20250629131113 -Anytime the component renders, React Scan will tell you what props, state, or context changed during the last render. If those values didn't change, and your component was wrapped in `React.memo`, it would not of rendered. - -To the right of the of the "Why did this component render" view, you will see the component tree of your app. When a component re-renders, the count will be updated in the tree. You can click on any item in the tree to see why it rendered. - - -### Profiling slowdowns in your app - -Re-render outlines are good for getting a high level overview of what's slowing down your app, and the "Why did this render" inspector is great when you know which component you want to debug. But, what if you don't know which components are causing your app to slowdown? - -React Scan's profiler, accessible through the notification bell in the toolbar: - -image - - -is an always on profiler that alerts you when there is an FPS drop or slow interaction (click, type). Every slowdown and interaction has an easy to understand profile associated with it. - - -https://github.com/user-attachments/assets/c7d72e57-d805-4f21-944b-2347b72b0304 - - - -The profile has 3 parts: -#### Ranked - -This ranks how long it took to render your components. Every component instance that came from the same component will have its render time added together- if you render 1000 `ListItem`'s , and they each take 1s to render, we will say `ListItem` took 1000s to render ) - -image - -If you click on any bar, it will tell you what caused those components to re-render: - -Pasted image 20250629132303 - -This table is telling you that there were 4 instances of this component rendered, and all 4 of them had their `close`, `style`, and `hide` props change. If those didn't change, and the component was `React.memo`'d, they would not have rendered - -If you click the arrow on the side of each bar, it will show you the ancestors of the components that rendered that component, along with how long it took to render that ancestor. This is great for giving context to understand what component you're looking at: - -image - -If you hover your mouse over a bar, all instances of that component will be outlined in purple over the page: - -image - - -#### Overview -The overview gives you a high level summary of what time was spent on during the slowdown or interaction. - -This breaks down if the time spent was on renders, react hooks (or other javascript not from react), or the browser spending time to update the dom and draw the next frame - -This is great to find out if React was really the problem, or if you should be optimizing other things, like CSS: -Pasted image 20250629132429 - -#### Prompts -The prompts section gives you 3 different kind of prompts that you can pass to an LLM based on what your goal is. These prompts automatically includes data about the profile: - -Pasted image 20250629132608 - - - -#### Misc -If you want to hear a sound every time a slowdown is collected, you can turn on audio alerts in this section - - -### Hiding the toolbar - -The React Scan toolbar can be distracting when you're not using it. To hide the toolbar, you can drag/throw it into the side of the page. - - - - - -The toolbar will stay collapsed into the side of the page until you drag it back out. This will persist across page load - - -
+Install the extension by following the guide [here](https://github.com/aidenybai/react-scan/blob/main/BROWSER_EXTENSION_GUIDE.md). ## API Reference @@ -215,61 +69,37 @@ The toolbar will stay collapsed into the side of the page until you drag it back export interface Options { /** * Enable/disable scanning - * - * Please use the recommended way: - * enabled: process.env.NODE_ENV === 'development', - * * @default true */ enabled?: boolean; /** * Force React Scan to run in production (not recommended) - * * @default false */ dangerouslyForceRunInProduction?: boolean; + /** * Log renders to the console - * - * WARNING: This can add significant overhead when the app re-renders frequently - * * @default false */ log?: boolean; /** * Show toolbar bar - * - * If you set this to true, and set {@link enabled} to false, the toolbar will still show, but scanning will be disabled. - * * @default true */ showToolbar?: boolean; /** * Animation speed - * * @default "fast" */ animationSpeed?: "slow" | "fast" | "off"; - /** - * Track unnecessary renders, and mark their outlines gray when detected - * - * An unnecessary render is defined as the component re-rendering with no change to the component's - * corresponding dom subtree - * - * @default false - * @warning tracking unnecessary renders can add meaningful overhead to react-scan - */ - trackUnnecessaryRenders?: boolean; - onCommitStart?: () => void; onRender?: (fiber: Fiber, renders: Array) => void; onCommitFinish?: () => void; - onPaintStart?: (outlines: Array) => void; - onPaintFinish?: (outlines: Array) => void; } ``` @@ -285,42 +115,33 @@ export interface Options { 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. +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 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 re-render and slow down the app, even if `ExpensiveComponent` was wrapped in React.memo: +However, this makes it easy to accidentally cause unnecessary renders, making the app slow. Even production apps with hundreds of engineers can't fully optimize their apps (see [GitHub](https://github.com/aidenybai/react-scan/blob/main/.github/assets/github.mp4), [Twitter](https://github.com/aidenybai/react-scan/blob/main/.github/assets/twitter.mp4), and [Instagram](https://github.com/aidenybai/react-scan/blob/main/.github/assets/instagram.mp4)). ```jsx alert("hi")} style={{ color: "purple" }} /> ``` -React Scan helps you identify these issues by automatically detecting and highlighting renders that cause performance issues. Now, instead of guessing, you can see exactly which components you need to fix. - -> Want monitor issues in production? Check out [React Scan Monitoring](https://react-scan.com/monitoring)! - +React Scan helps you identify these issues by automatically detecting and highlighting renders that cause performance issues. -## Resources & Contributing Back +## Resources & Contributing -Want to try it out? Check the [our demo](https://react-scan.million.dev). +Want to try it out? Check the [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. +Looking to contribute? Check the [Contributing Guide](https://github.com/aidenybai/react-scan/blob/main/CONTRIBUTING.md). -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. +Want to talk to the community? Join our [Discord](https://discord.gg/X9yFbcV2rF). -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). +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 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) +- [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 hijacking internals to detect unnecessary renders caused by "unstable" props +- [Why Did You Render?](https://github.com/welldone-software/why-did-you-render) for the concept of detecting unnecessary renders ## License diff --git a/biome.json b/biome.json deleted file mode 100644 index d00fd39f..00000000 --- a/biome.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "ignore": [ - "**/dist/**", - "**/build/**", - "node_modules", - "**/node_modules/**", - "**/*.css", - "**/*.astro", - "packages/website" - ] - }, - "organizeImports": { - "enabled": false - }, - "linter": { - "enabled": true, - "rules": { - "recommended": false, - "correctness": { - "noUnusedFunctionParameters": { - "level": "warn", - "fix": "unsafe" - }, - "noUnusedImports": { - "level": "warn", - "fix": "unsafe" - }, - "noUnusedLabels": { - "level": "warn", - "fix": "unsafe" - }, - "noUnusedPrivateClassMembers": { - "level": "warn", - "fix": "unsafe" - }, - "noUnusedVariables": { - "level": "warn", - "fix": "unsafe" - }, - "useExhaustiveDependencies": "warn" - }, - "suspicious": { - "noExplicitAny": "warn", - "noConsole": "warn" - }, - "security": { - "noDangerouslySetInnerHtml": "error" - }, - "style": { - "noNonNullAssertion": "warn" - } - } - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 80, - "lineEnding": "lf" - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "trailingCommas": "all" - } - } -} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..26e3678b --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,80 @@ +import { type Page } from '@playwright/test'; + +export const FIXTURE_URL = '/?example=e2e-fixture'; + +export async function gotoFixture(page: Page): Promise { + await page.goto(FIXTURE_URL); + await page.waitForSelector('[data-testid="heading"]', { timeout: 10_000 }); + // Wait for React Scan to boot and expose __REACT_SCAN__ + await page.waitForFunction( + () => typeof (window as any).__REACT_SCAN__?.ReactScanInternals !== 'undefined', + { timeout: 15_000 }, + ); + // Install a render counter by patching the onRender option on the signal + await page.evaluate(() => { + (window as any).__E2E_RENDER_COUNT__ = 0; + const internals = (window as any).__REACT_SCAN__?.ReactScanInternals; + if (internals?.options) { + const prev = internals.options.value; + const prevOnRender = prev.onRender; + internals.options.value = { + ...prev, + onRender: (...args: any[]) => { + (window as any).__E2E_RENDER_COUNT__++; + if (prevOnRender) prevOnRender(...args); + }, + }; + } + }); + // Wait for initial mount renders to settle then reset + await page.waitForTimeout(500); + await page.evaluate(() => { + (window as any).__E2E_RENDER_COUNT__ = 0; + }); +} + +export async function getRenderCount(page: Page): Promise { + return page.evaluate(() => (window as any).__E2E_RENDER_COUNT__ ?? 0); +} + +export async function waitForRenders( + page: Page, + timeout = 5000, +): Promise { + const startCount = await getRenderCount(page); + return page.evaluate( + ({ start, t }) => { + return new Promise((resolve) => { + const check = () => { + const current = (window as any).__E2E_RENDER_COUNT__ ?? 0; + if (current > start) { + resolve(current - start); + return true; + } + return false; + }; + if (check()) return; + const interval = setInterval(() => { + if (check()) clearInterval(interval); + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(0); + }, t); + }); + }, + { start: startCount, t: timeout }, + ); +} + +export async function isReactScanActive(page: Page): Promise { + return page.evaluate(() => { + return typeof (window as any).__REACT_SCAN__ !== 'undefined'; + }); +} + +export async function hasShadowRoot(page: Page): Promise { + return page.evaluate(() => { + return document.getElementById('react-scan-root')?.shadowRoot != null; + }); +} diff --git a/e2e/inspector.spec.ts b/e2e/inspector.spec.ts new file mode 100644 index 00000000..15c25d70 --- /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 00000000..d0d28067 --- /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 00000000..26135ea3 --- /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/toolbar.spec.ts b/e2e/toolbar.spec.ts new file mode 100644 index 00000000..34e147bd --- /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/src/examples/e2e-fixture/index.tsx b/kitchen-sink/src/examples/e2e-fixture/index.tsx new file mode 100644 index 00000000..8d976eae --- /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/package.json b/package.json index dc4b40db..3f69bd42 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,21 @@ "pack": "node scripts/workspace.mjs pack", "pack:bump": "pnpm --filter scan pack:bump", "lint": "pnpm -r lint", - "lint:all": "biome lint .", - "format": "biome format . --write", - "check": "biome check . --write", + "lint:all": "oxlint .", "changeset:add": "changeset add", "bump": "changset add", - "changeset:publish": "changeset publish" + "changeset:publish": "changeset publish", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.27.12", + "@playwright/test": "^1.58.2", "@types/node": "^22.10.2", "autoprefixer": "^10.4.20", "boxen": "^8.0.1", "chalk": "^5.3.0", + "oxlint": "latest", "postcss": "^8.5.3", "rimraf": "^6.0.1", "tailwindcss": "^3.4.17", diff --git a/packages/extension/package.json b/packages/extension/package.json index e8d0649a..a196a5fc 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -16,9 +16,7 @@ "pack:firefox": "pnpm clean && BROWSER=firefox pnpm build && pnpm mkdir && cd dist && zip -r \"../build/firefox-extension-v$npm_package_version.zip\" .", "pack:brave": "pnpm clean && BROWSER=brave pnpm build && pnpm mkdir && cd dist && zip -r \"../build/brave-extension-v$npm_package_version.zip\" .", "pack:all": "rimraf build && pnpm pack:chrome && pnpm pack:firefox && pnpm pack:brave", - "lint": "biome lint src && pnpm typecheck", - "format": "biome format . --write", - "check": "biome check . --write", + "lint": "oxlint src && pnpm typecheck", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 2673e6a0..6182f934 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -16,7 +16,7 @@ const injectScripts = async (tabId: number) => { type: 'react-scan:page-reload', }); } catch (e) { - // biome-ignore lint/suspicious/noConsole: log error + // oxlint-disable-next-line no-console console.error('Script injection error:', e); } }; diff --git a/packages/extension/src/inject/index.ts b/packages/extension/src/inject/index.ts index acf24b5d..b9a83d5e 100644 --- a/packages/extension/src/inject/index.ts +++ b/packages/extension/src/inject/index.ts @@ -129,18 +129,10 @@ window.addEventListener('DOMContentLoaded', async () => { } if (isTargetPageAlreadyUsedReactScan()) { - if (window.__REACT_SCAN__?.ReactScanInternals?.Store?.monitor?.value) { - createNotificationUI({ - title: 'Outdated React Scan Monitoring', - content: - 'If you are a developer of this website, please upgrade to the latest version of React Scan.', - }); - } else { - createNotificationUI({ - title: 'Already Initialized', - content: 'React Scan is already initialized on this page.', - }); - } + createNotificationUI({ + title: 'Already Initialized', + content: 'React Scan is already initialized on this page.', + }); busDispatch( 'react-scan:send-to-background', diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 2143e220..8c5bb970 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig(({ mode }): UserConfig => { // Validate Brave binary if (env.NODE_ENV === 'development' && isBrave && !env.BRAVE_BINARY) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.error(` ⚛️ React Scan ============== diff --git a/packages/scan/package.json b/packages/scan/package.json index 95c289f2..34f67a31 100644 --- a/packages/scan/package.json +++ b/packages/scan/package.json @@ -24,55 +24,22 @@ "url": "https://million.dev" }, "scripts": { - "dev:kitchen": "node dist/cli.js http://localhost:5173", "build": "npm run build:css && NODE_ENV=production tsup", - "postbuild": "pnpm copy-astro && node ../../scripts/version-warning.mjs", + "postbuild": "node ../../scripts/version-warning.mjs", "build:copy": "npm run build:css && NODE_ENV=production tsup && cat dist/auto.global.js | pbcopy", - "copy-astro": "cp -R src/core/monitor/params/astro dist/core/monitor/params", "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 copy-astro && pnpm run --parallel \"/^dev:(css|tsup)/\"", + "dev": "pnpm run --parallel \"/^dev:(css|tsup)/\"", "build:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css", "pack": "npm version patch && pnpm build && npm pack", "pack:bump": "bun scripts/bump-version.js && nr pack && echo $(pwd)/react-scan-$(node -p \"require('./package.json').version\").tgz | pbcopy", - "prettier": "prettier --config .prettierrc.mjs -w src", "publint": "publint", "test": "vitest", - "lint": "biome lint src && pnpm typecheck", - "format": "biome format . --write", - "check": "biome check . --write", + "lint": "oxlint src && pnpm typecheck", "typecheck": "tsc --noEmit" }, "exports": { "./package.json": "./package.json", - "./monitoring": { - "types": "./dist/core/monitor/index.d.ts", - "import": "./dist/core/monitor/index.mjs", - "require": "./dist/core/monitor/index.js" - }, - "./monitoring/next": { - "types": "./dist/core/monitor/params/next.d.ts", - "import": "./dist/core/monitor/params/next.mjs", - "require": "./dist/core/monitor/params/next.js" - }, - "./monitoring/react-router-legacy": { - "types": "./dist/core/monitor/params/react-router-v5.d.ts", - "import": "./dist/core/monitor/params/react-router-v5.mjs", - "require": "./dist/core/monitor/params/react-router-v5.js" - }, - "./monitoring/react-router": { - "types": "./dist/core/monitor/params/react-router-v6.d.ts", - "import": "./dist/core/monitor/params/react-router-v6.mjs", - "require": "./dist/core/monitor/params/react-router-v6.js" - }, - "./monitoring/remix": { - "types": "./dist/core/monitor/params/remix.d.ts", - "import": "./dist/core/monitor/params/remix.mjs", - "require": "./dist/core/monitor/params/remix.js" - }, - "./monitoring/astro": { - "import": "./dist/core/monitor/params/astro/index.ts" - }, ".": { "production": { "import": { @@ -197,24 +164,6 @@ "types": "dist/index.d.ts", "typesVersions": { "*": { - "monitoring": [ - "./dist/core/monitor/index.d.ts" - ], - "monitoring/next": [ - "./dist/core/monitor/params/next.d.ts" - ], - "monitoring/react-router-legacy": [ - "./dist/core/monitor/params/react-router-v5.d.ts" - ], - "monitoring/react-router": [ - "./dist/core/monitor/params/react-router-v6.d.ts" - ], - "monitoring/remix": [ - "./dist/core/monitor/params/remix.d.ts" - ], - "monitoring/astro": [ - "./dist/core/monitor/params/astro/index.ts" - ], "react-component-name/vite": [ "./dist/react-component-name/vite.d.ts" ], @@ -254,21 +203,20 @@ "@babel/core": "^7.26.0", "@babel/generator": "^7.26.2", "@babel/types": "^7.26.0", - "@clack/core": "^0.3.5", - "@clack/prompts": "^0.8.2", "@preact/signals": "^1.3.1", "@rollup/pluginutils": "^5.1.3", "@types/node": "^20.17.9", "bippy": "^0.3.33", + "commander": "^14.0.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", - "kleur": "^4.1.5", - "mri": "^1.2.0", - "playwright": "^1.49.0", - "preact": "^10.25.1" + "picocolors": "^1.1.1", + "preact": "^10.25.1", + "prompts": "^2.4.2" }, "devDependencies": { "@esbuild-plugins/tsconfig-paths": "^0.1.2", + "@types/prompts": "^2.4.9", "@remix-run/react": "*", "@types/babel__core": "^7.20.5", "@types/react": "^18.0.0", @@ -277,7 +225,6 @@ "es-module-lexer": "^1.5.4", "next": "*", "postcss-cli": "^11.0.0", - "prettier": "^3.3.3", "publint": "^0.2.12", "react": "*", "react-dom": "*", @@ -289,26 +236,8 @@ "vitest": "^3.0.0" }, "peerDependencies": { - "@remix-run/react": ">=1.0.0", - "next": ">=13.0.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", - "react-router": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react-router-dom": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@remix-run/react": { - "optional": true - }, - "next": { - "optional": true - }, - "react-router": { - "optional": true - }, - "react-router-dom": { - "optional": true - } + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalDependencies": { "unplugin": "2.1.0" diff --git a/packages/scan/scripts/bump-version.js b/packages/scan/scripts/bump-version.js index 0c0f0e6f..19061fb9 100644 --- a/packages/scan/scripts/bump-version.js +++ b/packages/scan/scripts/bump-version.js @@ -24,7 +24,7 @@ const tarFilePath = path.join(__dirname, '..', tarFileName); // Copy to clipboard execSync(`echo "${tarFilePath}" | pbcopy`); -// biome-ignore lint/suspicious/noConsole: Intended debug output +// oxlint-disable-next-line no-console console.log(`Bumped version to ${newVersion}`); -// biome-ignore lint/suspicious/noConsole: Intended debug output +// oxlint-disable-next-line no-console console.log(`Tar file path copied to clipboard: ${tarFilePath}`); diff --git a/packages/scan/src/cli-utils.mts b/packages/scan/src/cli-utils.mts new file mode 100644 index 00000000..88e2b4e4 --- /dev/null +++ b/packages/scan/src/cli-utils.mts @@ -0,0 +1,487 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; +type Framework = 'next' | 'vite' | 'tanstack' | 'webpack' | 'unknown'; +type NextRouterType = 'app' | 'pages' | 'unknown'; + +interface ProjectInfo { + packageManager: PackageManager; + framework: Framework; + nextRouterType: NextRouterType; + projectRoot: string; + hasReactScan: boolean; +} + +interface TransformResult { + success: boolean; + filePath: string; + message: string; + originalContent?: string; + newContent?: string; + noChanges?: boolean; +} + +interface DiffLine { + type: 'added' | 'removed' | 'unchanged'; + content: string; +} + +const FRAMEWORK_NAMES: Record = { + next: 'Next.js', + vite: 'Vite', + tanstack: 'TanStack Start', + webpack: 'Webpack', + unknown: 'Unknown', +}; + +const INSTALL_COMMANDS: Record = { + npm: 'npm install -D', + yarn: 'yarn add -D', + pnpm: 'pnpm add -D', + bun: 'bun add -D', +}; + +// --- Templates --- + +const REACT_SCAN_SCRIPT_TAG = ''; + +const NEXT_APP_ROUTER_SCRIPT = `{process.env.NODE_ENV === "development" && ( + `; + +const WEBPACK_IMPORT = `if (process.env.NODE_ENV === "development") { + import("react-scan"); +}`; + +// --- Detection --- + +const detectPackageManager = (projectRoot: string): PackageManager => { + if (existsSync(join(projectRoot, 'bun.lockb')) || existsSync(join(projectRoot, 'bun.lock'))) return 'bun'; + if (existsSync(join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(join(projectRoot, 'yarn.lock'))) return 'yarn'; + return 'npm'; +}; + +const detectFramework = (projectRoot: string): Framework => { + const packageJsonPath = join(projectRoot, 'package.json'); + if (!existsSync(packageJsonPath)) return 'unknown'; + + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + if (allDeps['next']) return 'next'; + if (allDeps['@tanstack/react-start']) return 'tanstack'; + if (allDeps['vite']) return 'vite'; + if (allDeps['webpack'] || allDeps['react-scripts']) return 'webpack'; + + return 'unknown'; + } catch { + return 'unknown'; + } +}; + +const detectNextRouterType = (projectRoot: string): NextRouterType => { + if (existsSync(join(projectRoot, 'app')) || existsSync(join(projectRoot, 'src', 'app'))) return 'app'; + if (existsSync(join(projectRoot, 'pages')) || existsSync(join(projectRoot, 'src', 'pages'))) return 'pages'; + return 'unknown'; +}; + +const detectProject = (cwd: string): ProjectInfo => { + const packageManager = detectPackageManager(cwd); + const framework = detectFramework(cwd); + const nextRouterType = framework === 'next' ? detectNextRouterType(cwd) : 'unknown'; + + const packageJsonPath = join(cwd, 'package.json'); + let hasReactScan = false; + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + hasReactScan = Boolean(allDeps['react-scan']); + } catch { /* */ } + } + + return { + packageManager, + framework, + nextRouterType, + projectRoot: cwd, + hasReactScan, + }; +}; + +// --- File Finding --- + +const findLayoutFile = ( + projectRoot: string, + routerType: NextRouterType, +): string | null => { + if (routerType === 'app') { + const candidates = [ + join(projectRoot, 'app', 'layout.tsx'), + join(projectRoot, 'app', 'layout.jsx'), + join(projectRoot, 'app', 'layout.js'), + join(projectRoot, 'src', 'app', 'layout.tsx'), + join(projectRoot, 'src', 'app', 'layout.jsx'), + join(projectRoot, 'src', 'app', 'layout.js'), + ]; + return candidates.find(existsSync) ?? null; + } + + if (routerType === 'pages') { + const candidates = [ + join(projectRoot, 'pages', '_document.tsx'), + join(projectRoot, 'pages', '_document.jsx'), + join(projectRoot, 'pages', '_document.js'), + join(projectRoot, 'src', 'pages', '_document.tsx'), + join(projectRoot, 'src', 'pages', '_document.jsx'), + join(projectRoot, 'src', 'pages', '_document.js'), + ]; + return candidates.find(existsSync) ?? null; + } + + return null; +}; + +const findIndexHtml = (projectRoot: string): string | null => { + const candidates = [ + join(projectRoot, 'index.html'), + join(projectRoot, 'public', 'index.html'), + join(projectRoot, 'src', 'index.html'), + ]; + return candidates.find(existsSync) ?? null; +}; + +const findEntryFile = (projectRoot: string): string | null => { + const candidates = [ + join(projectRoot, 'src', 'index.tsx'), + join(projectRoot, 'src', 'index.ts'), + join(projectRoot, 'src', 'index.jsx'), + join(projectRoot, 'src', 'index.js'), + join(projectRoot, 'src', 'main.tsx'), + join(projectRoot, 'src', 'main.ts'), + join(projectRoot, 'src', 'main.jsx'), + join(projectRoot, 'src', 'main.js'), + ]; + return candidates.find(existsSync) ?? null; +}; + +const hasReactScanCode = (content: string): boolean => { + return content.includes('react-scan') || content.includes('react_scan'); +}; + +// --- Transform --- + +const transformNextAppRouter = ( + projectRoot: string, + routerType: NextRouterType, +): TransformResult => { + const layoutPath = findLayoutFile(projectRoot, routerType); + if (!layoutPath) { + return { + success: false, + filePath: '', + message: 'Could not find app/layout.tsx', + }; + } + + const originalContent = readFileSync(layoutPath, 'utf-8'); + + if (hasReactScanCode(originalContent)) { + return { + success: true, + filePath: layoutPath, + message: 'React Scan is already installed.', + noChanges: true, + }; + } + + let newContent = originalContent; + + const headMatch = newContent.match(/]*>([\s\S]*?)<\/head>/); + if (headMatch) { + const headContent = headMatch[1]; + const injection = `\n ${NEXT_APP_ROUTER_SCRIPT}\n`; + newContent = newContent.replace( + `') - 4)}>${injection}${headContent}`, + ); + } else { + const bodyMatch = newContent.match(//); + if (bodyMatch) { + const injection = `\n ${NEXT_APP_ROUTER_SCRIPT}`; + newContent = newContent.replace( + bodyMatch[0], + `${bodyMatch[0]}${injection}`, + ); + } + } + + return { + success: true, + filePath: layoutPath, + message: 'Success', + originalContent, + newContent, + }; +}; + +const transformNextPagesRouter = ( + projectRoot: string, + routerType: NextRouterType, +): TransformResult => { + const documentPath = findLayoutFile(projectRoot, routerType); + if (!documentPath) { + return { + success: false, + filePath: '', + message: 'Could not find pages/_document.tsx', + }; + } + + const originalContent = readFileSync(documentPath, 'utf-8'); + + if (hasReactScanCode(originalContent)) { + return { + success: true, + filePath: documentPath, + message: 'React Scan is already installed.', + noChanges: true, + }; + } + + let newContent = originalContent; + + const headMatch = newContent.match(/([\s\S]*?)<\/Head>/); + if (headMatch) { + const injection = `\n ${NEXT_PAGES_ROUTER_SCRIPT}`; + newContent = newContent.replace('', `${injection}`); + } + + return { + success: true, + filePath: documentPath, + message: 'Success', + originalContent, + newContent, + }; +}; + +const transformVite = (projectRoot: string): TransformResult => { + const indexHtml = findIndexHtml(projectRoot); + if (!indexHtml) { + return { + success: false, + filePath: '', + message: 'Could not find index.html', + }; + } + + const originalContent = readFileSync(indexHtml, 'utf-8'); + + if (hasReactScanCode(originalContent)) { + return { + success: true, + filePath: indexHtml, + message: 'React Scan is already installed.', + noChanges: true, + }; + } + + let newContent = originalContent; + const headMatch = newContent.match(/([\s\S]*?)<\/head>/); + if (headMatch) { + newContent = newContent.replace( + '', + `\n ${VITE_SCRIPT}`, + ); + } + + return { + success: true, + filePath: indexHtml, + message: 'Success', + originalContent, + newContent, + }; +}; + +const transformWebpack = (projectRoot: string): TransformResult => { + const indexHtml = findIndexHtml(projectRoot); + if (indexHtml) { + const originalContent = readFileSync(indexHtml, 'utf-8'); + if (hasReactScanCode(originalContent)) { + return { + success: true, + filePath: indexHtml, + message: 'React Scan is already installed.', + noChanges: true, + }; + } + + let newContent = originalContent; + const headMatch = newContent.match(/([\s\S]*?)<\/head>/); + if (headMatch) { + newContent = newContent.replace( + '', + `\n ${REACT_SCAN_SCRIPT_TAG}`, + ); + } + + return { + success: true, + filePath: indexHtml, + message: 'Success', + originalContent, + newContent, + }; + } + + const entryFile = findEntryFile(projectRoot); + if (!entryFile) { + return { + success: false, + filePath: '', + message: 'Could not find entry file or index.html', + }; + } + + const originalContent = readFileSync(entryFile, 'utf-8'); + if (hasReactScanCode(originalContent)) { + return { + success: true, + filePath: entryFile, + message: 'React Scan is already installed.', + noChanges: true, + }; + } + + const newContent = `${WEBPACK_IMPORT}\n\n${originalContent}`; + + return { + success: true, + filePath: entryFile, + message: 'Success', + originalContent, + newContent, + }; +}; + +const previewTransform = ( + projectRoot: string, + framework: Framework, + nextRouterType: NextRouterType, +): TransformResult => { + switch (framework) { + case 'next': + return nextRouterType === 'pages' + ? transformNextPagesRouter(projectRoot, nextRouterType) + : transformNextAppRouter(projectRoot, nextRouterType); + case 'vite': + return transformVite(projectRoot); + case 'webpack': + return transformWebpack(projectRoot); + case 'tanstack': + case 'unknown': + default: + return { + success: false, + filePath: '', + message: `Framework "${framework}" is not yet supported by automatic setup. Visit https://github.com/aidenybai/react-scan#install for manual setup.`, + }; + } +}; + +// --- Diff --- + +const generateDiff = (original: string, updated: string): DiffLine[] => { + const originalLines = original.split('\n'); + const newLines = updated.split('\n'); + const diff: DiffLine[] = []; + + let originalIdx = 0; + let newIdx = 0; + + while (originalIdx < originalLines.length || newIdx < newLines.length) { + const originalLine = originalLines[originalIdx]; + const newLine = newLines[newIdx]; + + if (originalLine === newLine) { + diff.push({ type: 'unchanged', content: originalLine }); + originalIdx++; + newIdx++; + } else if (originalLine === undefined) { + diff.push({ type: 'added', content: newLine }); + newIdx++; + } else if (newLine === undefined) { + diff.push({ type: 'removed', content: originalLine }); + originalIdx++; + } else { + const originalInNew = newLines.indexOf(originalLine, newIdx); + const newInOriginal = originalLines.indexOf(newLine, originalIdx); + + if (originalInNew !== -1 && (newInOriginal === -1 || originalInNew - newIdx < newInOriginal - originalIdx)) { + while (newIdx < originalInNew) { + diff.push({ type: 'added', content: newLines[newIdx] }); + newIdx++; + } + } else if (newInOriginal !== -1) { + while (originalIdx < newInOriginal) { + diff.push({ type: 'removed', content: originalLines[originalIdx] }); + originalIdx++; + } + } else { + diff.push({ type: 'removed', content: originalLine }); + diff.push({ type: 'added', content: newLine }); + originalIdx++; + newIdx++; + } + } + } + + return diff; +}; + +export { + type DiffLine, + type Framework, + type NextRouterType, + type PackageManager, + type ProjectInfo, + type TransformResult, + FRAMEWORK_NAMES, + INSTALL_COMMANDS, + NEXT_APP_ROUTER_SCRIPT, + NEXT_PAGES_ROUTER_SCRIPT, + REACT_SCAN_SCRIPT_TAG, + VITE_SCRIPT, + WEBPACK_IMPORT, + detectFramework, + detectNextRouterType, + detectPackageManager, + detectProject, + findEntryFile, + findIndexHtml, + findLayoutFile, + generateDiff, + hasReactScanCode, + previewTransform, + transformNextAppRouter, + transformNextPagesRouter, + transformVite, + transformWebpack, +}; diff --git a/packages/scan/src/cli-utils.test.mts b/packages/scan/src/cli-utils.test.mts new file mode 100644 index 00000000..44f9d505 --- /dev/null +++ b/packages/scan/src/cli-utils.test.mts @@ -0,0 +1,700 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + NEXT_APP_ROUTER_SCRIPT, + NEXT_PAGES_ROUTER_SCRIPT, + REACT_SCAN_SCRIPT_TAG, + VITE_SCRIPT, + WEBPACK_IMPORT, + detectFramework, + detectNextRouterType, + detectPackageManager, + detectProject, + findEntryFile, + findIndexHtml, + findLayoutFile, + generateDiff, + hasReactScanCode, + previewTransform, + transformNextAppRouter, + transformNextPagesRouter, + transformVite, + transformWebpack, +} from './cli-utils.mjs'; + +let tempDirectory: string; + +beforeEach(() => { + tempDirectory = mkdtempSync(join(tmpdir(), 'react-scan-cli-test-')); +}); + +afterEach(() => { + rmSync(tempDirectory, { recursive: true, force: true }); +}); + +const writePackageJson = ( + directory: string, + dependencies: Record = {}, + devDependencies: Record = {}, +) => { + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ dependencies, devDependencies }), + ); +}; + +// --- detectPackageManager --- + +describe('detectPackageManager', () => { + it('returns bun when bun.lockb exists', () => { + writeFileSync(join(tempDirectory, 'bun.lockb'), ''); + expect(detectPackageManager(tempDirectory)).toBe('bun'); + }); + + it('returns bun when bun.lock exists', () => { + writeFileSync(join(tempDirectory, 'bun.lock'), ''); + expect(detectPackageManager(tempDirectory)).toBe('bun'); + }); + + it('returns pnpm when pnpm-lock.yaml exists', () => { + writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), ''); + expect(detectPackageManager(tempDirectory)).toBe('pnpm'); + }); + + it('returns yarn when yarn.lock exists', () => { + writeFileSync(join(tempDirectory, 'yarn.lock'), ''); + expect(detectPackageManager(tempDirectory)).toBe('yarn'); + }); + + it('defaults to npm when no lock file exists', () => { + expect(detectPackageManager(tempDirectory)).toBe('npm'); + }); + + it('prefers bun over pnpm when both lock files exist', () => { + writeFileSync(join(tempDirectory, 'bun.lockb'), ''); + writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), ''); + expect(detectPackageManager(tempDirectory)).toBe('bun'); + }); + + it('prefers pnpm over yarn when both lock files exist', () => { + writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), ''); + writeFileSync(join(tempDirectory, 'yarn.lock'), ''); + expect(detectPackageManager(tempDirectory)).toBe('pnpm'); + }); +}); + +// --- detectFramework --- + +describe('detectFramework', () => { + it('detects Next.js from dependencies', () => { + writePackageJson(tempDirectory, { next: '^14.0.0' }); + expect(detectFramework(tempDirectory)).toBe('next'); + }); + + it('detects Next.js from devDependencies', () => { + writePackageJson(tempDirectory, {}, { next: '^14.0.0' }); + expect(detectFramework(tempDirectory)).toBe('next'); + }); + + it('detects Vite from dependencies', () => { + writePackageJson(tempDirectory, {}, { vite: '^5.0.0' }); + expect(detectFramework(tempDirectory)).toBe('vite'); + }); + + it('detects TanStack Start from dependencies', () => { + writePackageJson(tempDirectory, { '@tanstack/react-start': '^1.0.0' }); + expect(detectFramework(tempDirectory)).toBe('tanstack'); + }); + + it('detects Webpack from dependencies', () => { + writePackageJson(tempDirectory, {}, { webpack: '^5.0.0' }); + expect(detectFramework(tempDirectory)).toBe('webpack'); + }); + + it('detects Webpack via react-scripts', () => { + writePackageJson(tempDirectory, { 'react-scripts': '^5.0.0' }); + expect(detectFramework(tempDirectory)).toBe('webpack'); + }); + + it('returns unknown when no framework is detected', () => { + writePackageJson(tempDirectory, { react: '^18.0.0' }); + expect(detectFramework(tempDirectory)).toBe('unknown'); + }); + + it('returns unknown when no package.json exists', () => { + expect(detectFramework(tempDirectory)).toBe('unknown'); + }); + + it('returns unknown when package.json is malformed', () => { + writeFileSync(join(tempDirectory, 'package.json'), 'not-json'); + expect(detectFramework(tempDirectory)).toBe('unknown'); + }); + + it('prefers Next.js over Vite when both are present', () => { + writePackageJson(tempDirectory, { next: '^14.0.0' }, { vite: '^5.0.0' }); + expect(detectFramework(tempDirectory)).toBe('next'); + }); +}); + +// --- detectNextRouterType --- + +describe('detectNextRouterType', () => { + it('detects app router from root app directory', () => { + mkdirSync(join(tempDirectory, 'app')); + expect(detectNextRouterType(tempDirectory)).toBe('app'); + }); + + it('detects app router from src/app directory', () => { + mkdirSync(join(tempDirectory, 'src', 'app'), { recursive: true }); + expect(detectNextRouterType(tempDirectory)).toBe('app'); + }); + + it('detects pages router from root pages directory', () => { + mkdirSync(join(tempDirectory, 'pages')); + expect(detectNextRouterType(tempDirectory)).toBe('pages'); + }); + + it('detects pages router from src/pages directory', () => { + mkdirSync(join(tempDirectory, 'src', 'pages'), { recursive: true }); + expect(detectNextRouterType(tempDirectory)).toBe('pages'); + }); + + it('returns unknown when no router directories exist', () => { + expect(detectNextRouterType(tempDirectory)).toBe('unknown'); + }); + + it('prefers app router when both app and pages directories exist', () => { + mkdirSync(join(tempDirectory, 'app')); + mkdirSync(join(tempDirectory, 'pages')); + expect(detectNextRouterType(tempDirectory)).toBe('app'); + }); +}); + +// --- detectProject --- + +describe('detectProject', () => { + it('detects a Next.js app router project with pnpm', () => { + writePackageJson(tempDirectory, { next: '^14.0.0', react: '^18.0.0' }); + writeFileSync(join(tempDirectory, 'pnpm-lock.yaml'), ''); + mkdirSync(join(tempDirectory, 'app')); + + const project = detectProject(tempDirectory); + expect(project.packageManager).toBe('pnpm'); + expect(project.framework).toBe('next'); + expect(project.nextRouterType).toBe('app'); + expect(project.projectRoot).toBe(tempDirectory); + expect(project.hasReactScan).toBe(false); + }); + + it('detects hasReactScan from dependencies', () => { + writePackageJson(tempDirectory, { 'react-scan': '^0.4.0', vite: '^5.0.0' }); + const project = detectProject(tempDirectory); + expect(project.hasReactScan).toBe(true); + }); + + it('detects hasReactScan from devDependencies', () => { + writePackageJson(tempDirectory, { vite: '^5.0.0' }, { 'react-scan': '^0.4.0' }); + const project = detectProject(tempDirectory); + expect(project.hasReactScan).toBe(true); + }); + + it('sets nextRouterType to unknown for non-Next.js frameworks', () => { + writePackageJson(tempDirectory, {}, { vite: '^5.0.0' }); + mkdirSync(join(tempDirectory, 'app')); + const project = detectProject(tempDirectory); + expect(project.nextRouterType).toBe('unknown'); + }); +}); + +// --- hasReactScanCode --- + +describe('hasReactScanCode', () => { + it('detects react-scan in content', () => { + expect(hasReactScanCode('import("react-scan")')).toBe(true); + }); + + it('detects react_scan in content', () => { + expect(hasReactScanCode('window.react_scan = true')).toBe(true); + }); + + it('returns false when not present', () => { + expect(hasReactScanCode('import React from "react"')).toBe(false); + }); + + it('detects react-scan in script tag', () => { + expect(hasReactScanCode('')).toBe(true); + }); +}); + +// --- findLayoutFile --- + +describe('findLayoutFile', () => { + it('finds app/layout.tsx for app router', () => { + mkdirSync(join(tempDirectory, 'app')); + const layoutPath = join(tempDirectory, 'app', 'layout.tsx'); + writeFileSync(layoutPath, ''); + expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath); + }); + + it('finds src/app/layout.tsx for app router', () => { + mkdirSync(join(tempDirectory, 'src', 'app'), { recursive: true }); + const layoutPath = join(tempDirectory, 'src', 'app', 'layout.tsx'); + writeFileSync(layoutPath, ''); + expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath); + }); + + it('finds app/layout.jsx for app router', () => { + mkdirSync(join(tempDirectory, 'app')); + const layoutPath = join(tempDirectory, 'app', 'layout.jsx'); + writeFileSync(layoutPath, ''); + expect(findLayoutFile(tempDirectory, 'app')).toBe(layoutPath); + }); + + it('finds pages/_document.tsx for pages router', () => { + mkdirSync(join(tempDirectory, 'pages')); + const documentPath = join(tempDirectory, 'pages', '_document.tsx'); + writeFileSync(documentPath, ''); + expect(findLayoutFile(tempDirectory, 'pages')).toBe(documentPath); + }); + + it('finds src/pages/_document.tsx for pages router', () => { + mkdirSync(join(tempDirectory, 'src', 'pages'), { recursive: true }); + const documentPath = join(tempDirectory, 'src', 'pages', '_document.tsx'); + writeFileSync(documentPath, ''); + expect(findLayoutFile(tempDirectory, 'pages')).toBe(documentPath); + }); + + it('returns null when no layout file exists for app router', () => { + expect(findLayoutFile(tempDirectory, 'app')).toBeNull(); + }); + + it('returns null when no document file exists for pages router', () => { + expect(findLayoutFile(tempDirectory, 'pages')).toBeNull(); + }); + + it('returns null for unknown router type', () => { + expect(findLayoutFile(tempDirectory, 'unknown')).toBeNull(); + }); +}); + +// --- findIndexHtml --- + +describe('findIndexHtml', () => { + it('finds root index.html', () => { + const indexPath = join(tempDirectory, 'index.html'); + writeFileSync(indexPath, ''); + expect(findIndexHtml(tempDirectory)).toBe(indexPath); + }); + + it('finds public/index.html', () => { + mkdirSync(join(tempDirectory, 'public')); + const indexPath = join(tempDirectory, 'public', 'index.html'); + writeFileSync(indexPath, ''); + expect(findIndexHtml(tempDirectory)).toBe(indexPath); + }); + + it('finds src/index.html', () => { + mkdirSync(join(tempDirectory, 'src')); + const indexPath = join(tempDirectory, 'src', 'index.html'); + writeFileSync(indexPath, ''); + expect(findIndexHtml(tempDirectory)).toBe(indexPath); + }); + + it('prefers root index.html over public/index.html', () => { + const rootPath = join(tempDirectory, 'index.html'); + writeFileSync(rootPath, ''); + mkdirSync(join(tempDirectory, 'public')); + writeFileSync(join(tempDirectory, 'public', 'index.html'), ''); + expect(findIndexHtml(tempDirectory)).toBe(rootPath); + }); + + it('returns null when no index.html exists', () => { + expect(findIndexHtml(tempDirectory)).toBeNull(); + }); +}); + +// --- findEntryFile --- + +describe('findEntryFile', () => { + it('finds src/index.tsx', () => { + mkdirSync(join(tempDirectory, 'src')); + const entryPath = join(tempDirectory, 'src', 'index.tsx'); + writeFileSync(entryPath, ''); + expect(findEntryFile(tempDirectory)).toBe(entryPath); + }); + + it('finds src/main.tsx', () => { + mkdirSync(join(tempDirectory, 'src')); + const entryPath = join(tempDirectory, 'src', 'main.tsx'); + writeFileSync(entryPath, ''); + expect(findEntryFile(tempDirectory)).toBe(entryPath); + }); + + it('finds src/index.js', () => { + mkdirSync(join(tempDirectory, 'src')); + const entryPath = join(tempDirectory, 'src', 'index.js'); + writeFileSync(entryPath, ''); + expect(findEntryFile(tempDirectory)).toBe(entryPath); + }); + + it('prefers src/index.tsx over src/main.tsx', () => { + mkdirSync(join(tempDirectory, 'src')); + const indexPath = join(tempDirectory, 'src', 'index.tsx'); + writeFileSync(indexPath, ''); + writeFileSync(join(tempDirectory, 'src', 'main.tsx'), ''); + expect(findEntryFile(tempDirectory)).toBe(indexPath); + }); + + it('returns null when no entry file exists', () => { + expect(findEntryFile(tempDirectory)).toBeNull(); + }); +}); + +// --- transformNextAppRouter --- + +describe('transformNextAppRouter', () => { + const LAYOUT_WITH_BODY = `export default function RootLayout({ children }) { + return ( + + {children} + + ); +}`; + + const LAYOUT_WITH_HEAD = `export default function RootLayout({ children }) { + return ( + + App + {children} + + ); +}`; + + it('returns failure when no layout file exists', () => { + const result = transformNextAppRouter(tempDirectory, 'app'); + expect(result.success).toBe(false); + expect(result.message).toContain('Could not find'); + }); + + it('injects script after body tag when no head tag exists', () => { + mkdirSync(join(tempDirectory, 'app')); + writeFileSync(join(tempDirectory, 'app', 'layout.tsx'), LAYOUT_WITH_BODY); + + const result = transformNextAppRouter(tempDirectory, 'app'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + expect(result.newContent).toContain(''); + }); + + it('reports already installed when react-scan is in content', () => { + mkdirSync(join(tempDirectory, 'app')); + writeFileSync( + join(tempDirectory, 'app', 'layout.tsx'), + 'import "react-scan";\n' + LAYOUT_WITH_BODY, + ); + + const result = transformNextAppRouter(tempDirectory, 'app'); + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + expect(result.message).toContain('already installed'); + }); + + it('preserves original content', () => { + mkdirSync(join(tempDirectory, 'app')); + writeFileSync(join(tempDirectory, 'app', 'layout.tsx'), LAYOUT_WITH_BODY); + + const result = transformNextAppRouter(tempDirectory, 'app'); + expect(result.originalContent).toBe(LAYOUT_WITH_BODY); + }); +}); + +// --- transformNextPagesRouter --- + +describe('transformNextPagesRouter', () => { + const DOCUMENT_WITH_HEAD = `import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +}`; + + it('returns failure when no _document file exists', () => { + const result = transformNextPagesRouter(tempDirectory, 'pages'); + expect(result.success).toBe(false); + expect(result.message).toContain('Could not find'); + }); + + it('injects script inside Head tag', () => { + mkdirSync(join(tempDirectory, 'pages')); + writeFileSync(join(tempDirectory, 'pages', '_document.tsx'), DOCUMENT_WITH_HEAD); + + const result = transformNextPagesRouter(tempDirectory, 'pages'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + expect(result.newContent).toContain(''); + }); + + it('reports already installed when react-scan is in content', () => { + mkdirSync(join(tempDirectory, 'pages')); + writeFileSync( + join(tempDirectory, 'pages', '_document.tsx'), + DOCUMENT_WITH_HEAD.replace('', ' + +`; + + it('returns failure when no index.html exists', () => { + const result = transformVite(tempDirectory); + expect(result.success).toBe(false); + expect(result.message).toContain('Could not find index.html'); + }); + + it('injects script inside head tag', () => { + writeFileSync(join(tempDirectory, 'index.html'), VITE_INDEX_HTML); + + const result = transformVite(tempDirectory); + expect(result.success).toBe(true); + expect(result.newContent).toContain(VITE_SCRIPT); + expect(result.newContent).toContain(''); + }); + + it('reports already installed when react-scan is in content', () => { + writeFileSync( + join(tempDirectory, 'index.html'), + VITE_INDEX_HTML.replace('', `\n ${VITE_SCRIPT}`), + ); + + const result = transformVite(tempDirectory); + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + }); + + it('preserves rest of the html', () => { + writeFileSync(join(tempDirectory, 'index.html'), VITE_INDEX_HTML); + + const result = transformVite(tempDirectory); + expect(result.newContent).toContain('
'); + expect(result.newContent).toContain('src="/src/main.tsx"'); + }); +}); + +// --- transformWebpack --- + +describe('transformWebpack', () => { + const WEBPACK_INDEX_HTML = ` + + + + React App + + +
+ +`; + + const WEBPACK_ENTRY = `import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')).render();`; + + it('injects script tag into index.html when it exists', () => { + mkdirSync(join(tempDirectory, 'public')); + writeFileSync(join(tempDirectory, 'public', 'index.html'), WEBPACK_INDEX_HTML); + + const result = transformWebpack(tempDirectory); + expect(result.success).toBe(true); + expect(result.newContent).toContain(REACT_SCAN_SCRIPT_TAG); + }); + + it('falls back to entry file import when no index.html exists', () => { + mkdirSync(join(tempDirectory, 'src')); + writeFileSync(join(tempDirectory, 'src', 'index.tsx'), WEBPACK_ENTRY); + + const result = transformWebpack(tempDirectory); + expect(result.success).toBe(true); + expect(result.newContent).toContain(WEBPACK_IMPORT); + expect(result.newContent).toContain(WEBPACK_ENTRY); + }); + + it('returns failure when no index.html or entry file exists', () => { + const result = transformWebpack(tempDirectory); + expect(result.success).toBe(false); + expect(result.message).toContain('Could not find'); + }); + + it('reports already installed via index.html', () => { + mkdirSync(join(tempDirectory, 'public')); + writeFileSync( + join(tempDirectory, 'public', 'index.html'), + WEBPACK_INDEX_HTML.replace('', `\n ${REACT_SCAN_SCRIPT_TAG}`), + ); + + const result = transformWebpack(tempDirectory); + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + }); + + it('reports already installed via entry file', () => { + mkdirSync(join(tempDirectory, 'src')); + writeFileSync( + join(tempDirectory, 'src', 'index.tsx'), + `import("react-scan");\n${WEBPACK_ENTRY}`, + ); + + const result = transformWebpack(tempDirectory); + expect(result.success).toBe(true); + expect(result.noChanges).toBe(true); + }); +}); + +// --- previewTransform --- + +describe('previewTransform', () => { + it('routes to Next.js app router transform', () => { + mkdirSync(join(tempDirectory, 'app')); + writeFileSync( + join(tempDirectory, 'app', 'layout.tsx'), + '', + ); + + const result = previewTransform(tempDirectory, 'next', 'app'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + }); + + it('routes to Next.js pages router transform', () => { + mkdirSync(join(tempDirectory, 'pages')); + writeFileSync( + join(tempDirectory, 'pages', '_document.tsx'), + '', + ); + + const result = previewTransform(tempDirectory, 'next', 'pages'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + }); + + it('routes to Vite transform', () => { + writeFileSync( + join(tempDirectory, 'index.html'), + '', + ); + + const result = previewTransform(tempDirectory, 'vite', 'unknown'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + }); + + it('routes to Webpack transform', () => { + mkdirSync(join(tempDirectory, 'public')); + writeFileSync( + join(tempDirectory, 'public', 'index.html'), + '', + ); + + const result = previewTransform(tempDirectory, 'webpack', 'unknown'); + expect(result.success).toBe(true); + expect(result.newContent).toContain('react-scan'); + }); + + it('returns failure for tanstack framework', () => { + const result = previewTransform(tempDirectory, 'tanstack', 'unknown'); + expect(result.success).toBe(false); + expect(result.message).toContain('not yet supported'); + }); + + it('returns failure for unknown framework', () => { + const result = previewTransform(tempDirectory, 'unknown', 'unknown'); + expect(result.success).toBe(false); + expect(result.message).toContain('not yet supported'); + }); +}); + +// --- generateDiff --- + +describe('generateDiff', () => { + it('returns empty diff for identical strings', () => { + const diff = generateDiff('hello\nworld', 'hello\nworld'); + expect(diff).toEqual([ + { type: 'unchanged', content: 'hello' }, + { type: 'unchanged', content: 'world' }, + ]); + }); + + it('detects added lines', () => { + const diff = generateDiff('line1\nline3', 'line1\nline2\nline3'); + const addedLines = diff.filter((diffLine) => diffLine.type === 'added'); + expect(addedLines.length).toBeGreaterThan(0); + expect(addedLines.some((diffLine) => diffLine.content === 'line2')).toBe(true); + }); + + it('detects removed lines', () => { + const diff = generateDiff('line1\nline2\nline3', 'line1\nline3'); + const removedLines = diff.filter((diffLine) => diffLine.type === 'removed'); + expect(removedLines.length).toBeGreaterThan(0); + expect(removedLines.some((diffLine) => diffLine.content === 'line2')).toBe(true); + }); + + it('detects replaced lines', () => { + const diff = generateDiff('hello', 'goodbye'); + expect(diff).toEqual([ + { type: 'removed', content: 'hello' }, + { type: 'added', content: 'goodbye' }, + ]); + }); + + it('handles empty original', () => { + const diff = generateDiff('', 'new line'); + expect(diff).toEqual([ + { type: 'removed', content: '' }, + { type: 'added', content: 'new line' }, + ]); + }); + + it('handles empty updated', () => { + const diff = generateDiff('old line', ''); + expect(diff).toEqual([ + { type: 'removed', content: 'old line' }, + { type: 'added', content: '' }, + ]); + }); + + it('handles multi-line additions in the middle', () => { + const original = '\n'; + const updated = '\n \n'; + const diff = generateDiff(original, updated); + + const addedLines = diff.filter((diffLine) => diffLine.type === 'added'); + expect(addedLines.length).toBe(1); + expect(addedLines[0].content).toContain('react-scan'); + }); +}); diff --git a/packages/scan/src/cli.mts b/packages/scan/src/cli.mts index a8831e01..465aed1b 100644 --- a/packages/scan/src/cli.mts +++ b/packages/scan/src/cli.mts @@ -1,319 +1,190 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { cancel, confirm, intro, isCancel, spinner } from '@clack/prompts'; -import { bgMagenta, dim, red } from 'kleur'; -import mri from 'mri'; +import { execSync } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { Command } from 'commander'; +import pc from 'picocolors'; +import prompts from 'prompts'; import { - type Browser, - type BrowserContext, - chromium, - devices, - firefox, - webkit, -} from 'playwright'; - -const truncateString = (str: string, maxLength: number) => { - let result = str - .replace('http://', '') - .replace('https://', '') - .replace('www.', ''); - - if (result.endsWith('/')) { - result = result.slice(0, -1); + type DiffLine, + type PackageManager, + FRAMEWORK_NAMES, + INSTALL_COMMANDS, + detectProject, + generateDiff, + previewTransform, +} from './cli-utils.mjs'; + +const VERSION = process.env.NPM_PACKAGE_VERSION ?? '0.0.0'; + +// --- Diff --- + +const printDiff = (filePath: string, original: string, updated: string): void => { + const diff = generateDiff(original, updated); + const contextLines = 3; + const changedIndices = diff + .map((line: DiffLine, i: number) => (line.type !== 'unchanged' ? i : -1)) + .filter((i: number) => i !== -1); + + if (changedIndices.length === 0) { + console.log(pc.dim(' No changes')); + return; } - if (result.length > maxLength) { - const half = Math.floor(maxLength / 2); - const start = result.slice(0, half); - const end = result.slice(result.length - (maxLength - half)); - return `${start}…${end}`; - } - return result; -}; + console.log(`\n${pc.bold(`File: ${filePath}`)}`); + console.log(pc.dim('─'.repeat(60))); -const inferValidURL = (maybeURL: string) => { - try { - return new URL(maybeURL).href; - } catch { - try { - return new URL(`https://${maybeURL}`).href; - } catch { - return 'about:blank'; - } - } -}; + let lastPrintedIdx = -1; -const getBrowserDetails = async (browserType: string) => { - switch (browserType) { - case 'firefox': - return { browserType: firefox, channel: undefined, name: 'firefox' }; - case 'webkit': - return { browserType: webkit, channel: undefined, name: 'webkit' }; - default: - return { browserType: chromium, channel: 'chrome', name: 'chrome' }; - } -}; + for (const changedIdx of changedIndices) { + const start = Math.max(0, changedIdx - contextLines); + const end = Math.min(diff.length - 1, changedIdx + contextLines); -const userAgentStrings = [ - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.2227.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.3497.92 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36', -]; - -const applyStealthScripts = async (context: BrowserContext) => { - await context.addInitScript(() => { - // Override the navigator.webdriver property - Object.defineProperty(navigator, 'webdriver', { - get: () => undefined, - }); - - // Mock languages and plugins to mimic a real browser - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'], - }); - - Object.defineProperty(navigator, 'plugins', { - get: () => [1, 2, 3, 4, 5], - }); - - // Remove Playwright-specific properties - interface PlaywrightWindow extends Window { - __playwright?: unknown; - __pw_manual?: unknown; - __PW_inspect?: unknown; + if (start > lastPrintedIdx + 1 && lastPrintedIdx !== -1) { + console.log(pc.dim(' ...')); } - const win = window as PlaywrightWindow; - win.__playwright = undefined; - win.__pw_manual = undefined; - win.__PW_inspect = undefined; - - // Redefine the headless property - Object.defineProperty(navigator, 'headless', { - get: () => false, - }); - - // Override the permissions API - const originalQuery = window.navigator.permissions.query; - window.navigator.permissions.query = (parameters) => - parameters.name === 'notifications' - ? Promise.resolve({ - state: Notification.permission, - } as PermissionStatus) - : originalQuery(parameters); - }); -}; - -const init = async () => { - intro(`${bgMagenta('[·]')} React Scan`); - const args = mri(process.argv.slice(2)); - let browser: Browser | undefined; - - const device = devices[args.device]; - const { browserType, channel } = await getBrowserDetails(args.browser); - - const contextOptions = { - headless: false, - channel, - ...device, - acceptDownloads: true, - viewport: null, - locale: 'en-US', - timezoneId: 'America/New_York', - args: [ - '--enable-webgl', - '--use-gl=swiftshader', - '--enable-accelerated-2d-canvas', - '--disable-blink-features=AutomationControlled', - '--disable-web-security', - ], - userAgent: - userAgentStrings[Math.floor(Math.random() * userAgentStrings.length)], - bypassCSP: true, - ignoreHTTPSErrors: true, - }; - - try { - browser = await browserType.launch({ - headless: false, - channel, - }); - } catch { - /**/ - } - - if (!browser) { - try { - browser = await browserType.launch({ headless: false }); - } catch { - const installPromise = new Promise((resolve, reject) => { - const runInstall = () => { - confirm({ - message: - 'No drivers found. Install Playwright Chromium driver to continue?', - }).then((shouldInstall) => { - if (isCancel(shouldInstall)) { - cancel('Operation cancelled.'); - process.exit(0); - } - if (!shouldInstall) { - process.exit(0); - } - - const installProcess = spawn( - 'npx', - ['playwright@latest', 'install', 'chromium'], - { stdio: 'inherit' }, - ); - - installProcess.on('close', (code) => { - if (!code) resolve(); - else - reject( - new Error(`Installation process exited with code ${code}`), - ); - }); - - installProcess.on('error', reject); - }); - }; - - runInstall(); - }); - - await installPromise; - - try { - browser = await chromium.launch({ headless: false }); - } catch { - cancel( - 'No browser could be launched. Please run `npx playwright install` to install browser drivers.', - ); + for (let i = Math.max(start, lastPrintedIdx + 1); i <= end; i++) { + const line = diff[i]; + if (line.type === 'added') { + console.log(pc.green(`+ ${line.content}`)); + } else if (line.type === 'removed') { + console.log(pc.red(`- ${line.content}`)); + } else { + console.log(pc.dim(` ${line.content}`)); } + lastPrintedIdx = i; } } - if (!browser) { - cancel( - 'No browser could be launched. Please run `npx playwright install` to install browser drivers.', - ); - return; - } + console.log(pc.dim('─'.repeat(60))); +}; + +// --- Install --- - const context = await browser.newContext(contextOptions); - await applyStealthScripts(context); +const installPackages = ( + packages: string[], + packageManager: PackageManager, + projectRoot: string, +): void => { + if (packages.length === 0) return; - const scriptContent = await fs.readFile( - path.resolve(__dirname, './auto.global.js'), - 'utf8', - ); + const command = `${INSTALL_COMMANDS[packageManager]} ${packages.join(' ')}`; + console.log(pc.dim(` Running: ${command}\n`)); - // Add React Scan script at context level so it's available for all pages - await context.addInitScript({ - content: `window.hideIntro = true;${scriptContent}\n//# sourceURL=react-scan.js`, + execSync(command, { + cwd: projectRoot, + stdio: 'inherit', }); +}; - const page = await context.newPage(); +// --- Main --- - const inputUrl = args._[0] || 'about:blank'; +const program = new Command() + .name('react-scan') + .description('React Scan CLI') + .version(VERSION); - const urlString = inferValidURL(inputUrl); +program + .command('init') + .description('Set up React Scan in your project') + .option('-y, --yes', 'skip confirmation prompts', false) + .option('-c, --cwd ', 'working directory', process.cwd()) + .option('--skip-install', 'skip package installation', false) + .action(async (opts) => { + console.log(`\n${pc.magenta('[·]')} ${pc.bold('React Scan')} ${pc.dim(`v${VERSION}`)}\n`); - await page.goto(urlString); + try { + const cwd = resolve(opts.cwd); - await page.waitForLoadState('load'); - await page.waitForTimeout(500); + if (!existsSync(cwd)) { + console.error(pc.red(`Directory does not exist: ${cwd}`)); + process.exit(1); + } - const pollReport = async () => { - if (page.url() !== currentURL) return; - await page.evaluate(() => { - const globalHook = globalThis.__REACT_SCAN__; - if (!globalHook) return; - let count = 0; - globalHook.ReactScanInternals.onRender = (_fiber, renders) => { - let localCount = 0; - for (const render of renders) { - localCount += render.count; - } - count = localCount; - }; - const reportData = globalHook.ReactScanInternals.Store.reportData; - if (!Object.keys(reportData).length) return; + if (!existsSync(join(cwd, 'package.json'))) { + console.error(pc.red('No package.json found. Run this command from a project root.')); + process.exit(1); + } - // biome-ignore lint/suspicious/noConsole: Intended debug output - console.log('REACT_SCAN_REPORT', count); - }); - }; + console.log(pc.dim(' Detecting project...\n')); - let count = 0; - let currentSpinner: ReturnType | undefined; - let currentURL = urlString; + const project = detectProject(cwd); - let interval: ReturnType; + if (project.framework === 'unknown') { + console.error(pc.red(' Could not detect a supported framework.')); + console.log(pc.dim(' React Scan supports Next.js, Vite, and Webpack projects.')); + console.log(pc.dim(' Visit https://github.com/aidenybai/react-scan#install for manual setup.\n')); + process.exit(1); + } - const inject = async (url: string) => { - if (interval) clearInterval(interval); - currentURL = url; - const truncatedURL = truncateString(url, 35); + console.log(` Framework: ${pc.cyan(FRAMEWORK_NAMES[project.framework])}`); + if (project.framework === 'next') { + console.log(` Router: ${pc.cyan(project.nextRouterType === 'app' ? 'App Router' : 'Pages Router')}`); + } + console.log(` Package manager: ${pc.cyan(project.packageManager)}`); + console.log(); - // biome-ignore lint/suspicious/noConsole: - console.log(dim(`Scanning: ${truncatedURL}`)); - count = 0; + if (project.hasReactScan) { + console.log(pc.green(' React Scan is already installed in package.json.')); + console.log(pc.dim(' Checking if code setup is needed...\n')); + } - try { - await page.waitForLoadState('load'); - await page.waitForTimeout(500); + const result = previewTransform(cwd, project.framework, project.nextRouterType); - const hasReactScan = await page.evaluate(() => { - return Boolean(globalThis.__REACT_SCAN__); - }); + if (!result.success) { + console.error(pc.red(` ${result.message}\n`)); + process.exit(1); + } - if (!hasReactScan) { - // Script is already registered at context level, just reload - await page.reload(); - return; + if (result.noChanges) { + console.log(pc.green(' React Scan is already set up in your project.\n')); + process.exit(0); } - await page.waitForTimeout(100); + if (result.originalContent && result.newContent) { + printDiff( + relative(cwd, result.filePath), + result.originalContent, + result.newContent, + ); - interval = setInterval(() => { - pollReport().catch(() => {}); - }, 1000); - } catch { - // biome-ignore lint/suspicious/noConsole: - console.log(red(`Error: ${truncatedURL}`)); - } - }; + console.log(); + console.log(pc.yellow(' Auto-detection may not be 100% accurate.')); + console.log(pc.yellow(' Please verify the changes before committing.\n')); - await inject(urlString); + if (!opts.yes) { + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: 'Apply these changes?', + initial: true, + }); - page.on('framenavigated', async (frame) => { - if (frame !== page.mainFrame()) return; - const url = frame.url(); - inject(url); - }); + if (!proceed) { + console.log(pc.dim('\n Changes cancelled.\n')); + process.exit(0); + } + } + } - page.on('console', async (msg) => { - const text = msg.text(); - if (!text.startsWith('REACT_SCAN_REPORT')) { - return; - } - const reportDataString = text.replace('REACT_SCAN_REPORT', '').trim(); - try { - count = Number.parseInt(reportDataString, 10); - } catch { - return; - } + if (!opts.skipInstall && !project.hasReactScan) { + console.log(pc.dim('\n Installing react-scan...\n')); + installPackages(['react-scan'], project.packageManager, cwd); + console.log(); + } - const truncatedURL = truncateString(currentURL, 50); - if (currentSpinner) { - currentSpinner.message( - dim(`Scanning: ${truncatedURL}${count ? ` (×${count})` : ''}`), - ); + if (result.newContent) { + writeFileSync(result.filePath, result.newContent, 'utf-8'); + console.log(pc.green(` Updated ${relative(cwd, result.filePath)}`)); + } + + console.log(); + console.log(`${pc.green(' Success!')} React Scan has been installed.`); + console.log(pc.dim(' You may now start your development server.\n')); + } catch (error) { + console.error(pc.red(`\n Error: ${error instanceof Error ? error.message : String(error)}\n`)); + process.exit(1); } }); -}; -void init(); +program.parse(); diff --git a/packages/scan/src/core/index.ts b/packages/scan/src/core/index.ts index 9dfb79b1..1a07399e 100644 --- a/packages/scan/src/core/index.ts +++ b/packages/scan/src/core/index.ts @@ -14,15 +14,12 @@ import styles from '~web/assets/css/styles.css'; import { createToolbar } from '~web/toolbar'; import { IS_CLIENT } from '~web/utils/constants'; import { readLocalStorage, saveLocalStorage } from '~web/utils/helpers'; -import type { Outline } from '~web/utils/outline'; import type { States } from '~web/views/inspector/utils'; import type { ChangeReason, Render, createInstrumentation, } from './instrumentation'; -import type { InternalInteraction } from './monitor/types'; -import type { getSession } from './monitor/utils'; import { startTimingTracking } from './notifications/event-tracking'; import { createHighlightCanvas } from './notifications/outline-overlay'; import packageJson from '../../package.json'; @@ -30,9 +27,6 @@ import packageJson from '../../package.json'; let rootContainer: HTMLDivElement | null = null; let shadowRoot: ShadowRoot | null = null; -// @TODO: @pivanov - add back in when options are implemented -// const audioContext: AudioContext | null = null; - interface RootContainer { rootContainer: HTMLDivElement; shadowRoot: ShadowRoot; @@ -58,48 +52,6 @@ const initRootContainer = (): RootContainer => { return { rootContainer, shadowRoot }; }; -// export interface UnstableOptions { -// /** -// * 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; - -// /** -// * Animation speed -// * -// * @default "fast" -// */ -// animationSpeed?: 'slow' | 'fast' | 'off'; - -// /** -// * Smoothly animate the re-render outline when the element moves -// * -// * @default true -// */ -// smoothlyAnimateOutlines?: 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; -// } - export interface Options { /** * Enable/disable scanning @@ -186,29 +138,6 @@ export interface Options { onCommitStart?: () => void; onRender?: (fiber: Fiber, renders: Array) => void; onCommitFinish?: () => void; - onPaintStart?: (outlines: Array) => void; - onPaintFinish?: (outlines: Array) => void; -} - -export type MonitoringOptions = Pick< - Options, - | 'enabled' - | 'onCommitStart' - | 'onCommitFinish' - | 'onPaintStart' - | 'onPaintFinish' - | 'onRender' ->; - -interface Monitor { - pendingRequests: number; - interactions: Array; - session: ReturnType; - url: string | null; - route: string | null; - apiKey: string | null; - commit: string | null; - branch: string | null; } export interface StoreType { @@ -216,7 +145,6 @@ export interface StoreType { wasDetailsOpen: Signal; lastReportTime: Signal; isInIframe: Signal; - monitor: Signal; fiberRoots: WeakSet; reportData: Map; legacyReportData: Map; @@ -232,9 +160,6 @@ export interface Internals { instrumentation: ReturnType | null; componentAllowList: WeakMap, Options> | null; options: Signal; - scheduledOutlines: Map; // we clear t,his nearly immediately, so no concern of mem leak on the fiber - // outlines at the same coordinates always get merged together, so we pre-compute the merge ahead of time when aggregating in activeOutlines - activeOutlines: Map; // we re-use the outline object on the scheduled outline onRender: ((fiber: Fiber, renders: Array) => void) | null; Store: StoreType; version: string; @@ -292,7 +217,6 @@ export const Store: StoreType = { inspectState: signal({ kind: 'uninitialized', }), - monitor: signal(null), fiberRoots: new Set(), reportData: new Map(), legacyReportData: new Map(), @@ -306,25 +230,16 @@ export const ReactScanInternals: Internals = { componentAllowList: null, options: signal({ enabled: true, - // includeChildren: true, - // playSound: false, log: false, showToolbar: true, - // renderCountThreshold: 0, - // report: undefined, - // alwaysShowLabels: false, animationSpeed: 'fast', dangerouslyForceRunInProduction: false, showFPS: true, showNotificationCount: true, allowInIframe: false, - // smoothlyAnimateOutlines: true, - // trackUnnecessaryRenders: false, }), runInAllEnvironments: false, onRender: null, - scheduledOutlines: new Map(), - activeOutlines: new Map(), Store, version: packageJson.version, }; @@ -335,11 +250,7 @@ if (IS_CLIENT && window.__REACT_SCAN_EXTENSION__) { export type LocalStorageOptions = Omit< Options, - | 'onCommitStart' - | 'onRender' - | 'onCommitFinish' - | 'onPaintStart' - | 'onPaintFinish' + 'onCommitStart' | 'onRender' | 'onCommitFinish' >; const applyLocalStorageOptions = (options: Options): LocalStorageOptions => { @@ -347,8 +258,6 @@ const applyLocalStorageOptions = (options: Options): LocalStorageOptions => { onCommitStart, onRender, onCommitFinish, - onPaintStart, - onPaintFinish, ...rest } = options; return rest; @@ -362,11 +271,8 @@ const validateOptions = (options: Partial): Partial => { const value = options[key as keyof Options]; switch (key) { case 'enabled': - // case 'includeChildren': case 'log': case 'showToolbar': - // case 'report': - // case 'alwaysShowLabels': case 'showNotificationCount': case 'dangerouslyForceRunInProduction': case 'showFPS': @@ -377,14 +283,6 @@ const validateOptions = (options: Partial): Partial => { validOptions[key] = value; } break; - // case 'renderCountThreshold': - // case 'resetCountTimeout': - // if (typeof value !== 'number' || value < 0) { - // errors.push(`- ${key} must be a non-negative number. Got "${value}"`); - // } else { - // validOptions[key] = value as number; - // } - // break; case 'animationSpeed': if (!['slow', 'fast', 'off'].includes(value as string)) { errors.push( @@ -418,31 +316,13 @@ const validateOptions = (options: Partial): Partial => { ) => void; } break; - case 'onPaintStart': - case 'onPaintFinish': - if (typeof value !== 'function') { - errors.push(`- ${key} must be a function. Got "${value}"`); - } else { - validOptions[key] = value as (outlines: Array) => void; - } - break; - // case 'trackUnnecessaryRenders': { - // validOptions.trackUnnecessaryRenders = - // typeof value === 'boolean' ? value : false; - // break; - // } - // case 'smoothlyAnimateOutlines': { - // validOptions.smoothlyAnimateOutlines = - // typeof value === 'boolean' ? value : false; - // break; - // } default: errors.push(`- Unknown option "${key}"`); } } if (errors.length > 0) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.warn(`[React Scan] Invalid options:\n${errors.join('\n')}`); } @@ -496,7 +376,7 @@ export const setOptions = (userOptions: Partial) => { } } catch (e) { if (ReactScanInternals.options.value._debug === 'verbose') { - // biome-ignore lint/suspicious/noConsole: intended debug output + // oxlint-disable-next-line no-console console.error( '[React Scan Internal Error]', 'Failed to create notifications outline canvas', @@ -518,7 +398,7 @@ export const setOptions = (userOptions: Partial) => { return newOptions; } catch (e) { if (ReactScanInternals.options.value._debug === 'verbose') { - // biome-ignore lint/suspicious/noConsole: intended debug output + // oxlint-disable-next-line no-console console.error( '[React Scan Internal Error]', 'Failed to create notifications outline canvas', @@ -582,10 +462,10 @@ export const start = () => { initToolbar(!!options.value.showToolbar); }); - if (!Store.monitor.value && IS_CLIENT) { + if (IS_CLIENT) { setTimeout(() => { if (isInstrumentationActive()) return; - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.error( '[React Scan] Failed to load. Must import React Scan before React runs.', ); @@ -593,7 +473,7 @@ export const start = () => { } } catch (e) { if (ReactScanInternals.options.value._debug === 'verbose') { - // biome-ignore lint/suspicious/noConsole: intended debug output + // oxlint-disable-next-line no-console console.error( '[React Scan Internal Error]', 'Failed to create notifications outline canvas', @@ -632,7 +512,7 @@ const createNotificationsOutlineCanvas = () => { return createHighlightCanvas(highlightRoot); } catch (e) { if (ReactScanInternals.options.value._debug === 'verbose') { - // biome-ignore lint/suspicious/noConsole: intended debug output + // oxlint-disable-next-line no-console console.error( '[React Scan Internal Error]', 'Failed to create notifications outline canvas', diff --git a/packages/scan/src/core/instrumentation.ts b/packages/scan/src/core/instrumentation.ts index b99eb73b..15e0e43e 100644 --- a/packages/scan/src/core/instrumentation.ts +++ b/packages/scan/src/core/instrumentation.ts @@ -22,10 +22,6 @@ import { } from 'bippy'; import { isValidElement } from 'preact'; import { isEqual } from '~core/utils'; -import { - RENDER_PHASE_STRING_TO_ENUM, - type RenderPhase, -} from '~web/utils/outline'; import { collectContextChanges, collectPropsChanges, @@ -38,6 +34,38 @@ import { type StateChange, } from './index'; +export enum RenderPhase { + Mount = 0b001, + Update = 0b010, + Unmount = 0b100, +} + +export const RENDER_PHASE_STRING_TO_ENUM = { + mount: RenderPhase.Mount, + update: RenderPhase.Update, + unmount: RenderPhase.Unmount, +} as const; + +export interface AggregatedChange { + type: number; + unstable: boolean; +} + +export interface AggregatedRender { + name: string; + frame: number | null; + phase: number; + time: number | null; + aggregatedCount: number; + forget: boolean; + changes: AggregatedChange; + unnecessary: boolean | null; + didCommit: boolean; + fps: number; + computedKey: import('./index').OutlineKey | null; + computedCurrent: DOMRect | null; +} + let fps = 0; let lastTime = performance.now(); let frameCount = 0; @@ -406,9 +434,9 @@ export interface OldRenderData { time: number; renders: Array; displayName: string | null; - // biome-ignore lint/suspicious/noExplicitAny: temporary type hack cause im lazy + // oxlint-disable-next-line typescript/no-explicit-any type: any; - // biome-ignore lint/suspicious/noExplicitAny: temporary type hack cause im lazy + // oxlint-disable-next-line typescript/no-explicit-any changes?: any; } diff --git a/packages/scan/src/core/monitor/constants.ts b/packages/scan/src/core/monitor/constants.ts deleted file mode 100644 index 00db16bd..00000000 --- a/packages/scan/src/core/monitor/constants.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * We do prototype caching for highly performant code, do not put browser specific code here without a guard. - * - * _{global} is also a hack that reduces the size of the bundle - * - * Examples: - * @see https://github.com/ged-odoo/blockdom/blob/5849f0887ff8dc7f3f173f870ed850a89946fcfd/src/block_compiler.ts#L9 - * @see https://github.com/localvoid/ivi/blob/bd5bbe8c6b39a7be1051c16ea0a07b3df9a178bd/packages/ivi/src/client/core.ts#L13 - */ - -/** - * Do not destructure exports or import React from "react" here. - * From empirical ad-hoc testing, this breaks in certain scenarios. - */ -import * as React from 'react'; -import { IS_CLIENT } from '~web/utils/constants'; - -/** - * useRef will be undefined in "use server" - * - * @see https://nextjs.org/docs/messages/react-client-hook-in-server-component - */ -const isRSC = () => !React.useRef; -export const isSSR = () => !IS_CLIENT || isRSC(); - -interface WindowWithCypress extends Window { - Cypress?: unknown; -} - -export const isTest = - (IS_CLIENT && - /** - * @see https://docs.cypress.io/faq/questions/using-cypress-faq#Is-there-any-way-to-detect-if-my-app-is-running-under-Cypress - */ - ((window as WindowWithCypress).Cypress || - /** - * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/webdriver - */ - navigator.webdriver)) || - /** - * @see https://stackoverflow.com/a/60491322 - */ - // @ts-expect-error jest is a global in test - typeof jest !== 'undefined'; - -export const VERSION = null; // todo -export const PAYLOAD_VERSION = null; // todo - -export const MAX_QUEUE_SIZE = 300; -export const FLUSH_TIMEOUT = isTest - ? 100 // Make sure there is no data loss in tests - : process.env.NODE_ENV === 'production' - ? 5000 - : 1000; -export const SESSION_EXPIRE_TIMEOUT = 300000; // 5 minutes -export const GZIP_MIN_LEN = 1000; -export const GZIP_MAX_LEN = 60000; // 1 minute -export const MAX_PENDING_REQUESTS = 15; diff --git a/packages/scan/src/core/monitor/index.ts b/packages/scan/src/core/monitor/index.ts deleted file mode 100644 index 61793d76..00000000 --- a/packages/scan/src/core/monitor/index.ts +++ /dev/null @@ -1,200 +0,0 @@ -'use client'; -import { - type Fiber, - getDisplayName, - getTimings, - isCompositeFiber, -} from 'bippy'; -import { type FC, useEffect } from 'react'; -import { IS_CLIENT } from '~web/utils/constants'; -import { - type MonitoringOptions, - ReactScanInternals, - Store, - setOptions, -} from '..'; -import { type Render, createInstrumentation } from '../instrumentation'; -import { updateFiberRenderData } from '../utils'; -import { flush } from './network'; -import { computeRoute } from './params/utils'; -import { initPerformanceMonitoring } from './performance'; -import { getSession } from './utils'; - -// max retries before the set of components do not get reported (avoid memory leaks of the set of fibers stored on the component aggregation) -const MAX_RETRIES_BEFORE_COMPONENT_GC = 7; - -export interface MonitoringProps { - url?: string; - apiKey: string; - - // For Session and Interaction - path?: string | null; // pathname (i.e /foo/2/bar/3) - route?: string | null; // computed from path and params (i.e /foo/:fooId/bar/:barId) - - // Only used / should be provided to compute the route when using Monitoring without supported framework - params?: Record; - - // Tracking regressions across commits and branches - commit?: string | null; - branch?: string | null; -} - -export type MonitoringWithoutRouteProps = Omit< - MonitoringProps, - 'route' | 'path' ->; - -const DEFAULT_URL = 'https://monitoring.react-scan.com/api/v1/ingest'; - -function noopCatch() { - return null; -} - -export const Monitoring: FC = ({ - url, - apiKey, - params, - path = null, // path passed down would be reactive - route = null, - commit = null, - branch = null, -}) => { - if (!apiKey) - throw new Error('Please provide a valid API key for React Scan monitoring'); - url ??= DEFAULT_URL; - - Store.monitor.value ??= { - pendingRequests: 0, - interactions: [], - session: getSession({ commit, branch }).catch(noopCatch), - url, - apiKey, - route, - commit, - branch, - }; - - // When using Monitoring without framework, we need to compute the route from the path and params - if (!route && path && params) { - Store.monitor.value.route = computeRoute(path, params); - } else if (IS_CLIENT) { - Store.monitor.value.route = - route ?? path ?? new URL(window.location.toString()).pathname; // this is inaccurate on vanilla react if the path is not provided but used for session route - } - - useEffect(() => { - scanMonitoring({ enabled: true }); - return initPerformanceMonitoring(); - }, []); - - return null; -}; - -export const scanMonitoring = (options: MonitoringOptions) => { - setOptions(options); - startMonitoring(); -}; - -let flushInterval: ReturnType; - -export const startMonitoring = (): void => { - if (!Store.monitor.value) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant: startMonitoring can never be called when monitoring is not initialized', - ); - } - } - - if (flushInterval) { - clearInterval(flushInterval); - } - - flushInterval = setInterval(() => { - try { - void flush(); - } catch { - /* */ - } - }, 2000); - - if (!window.__REACT_SCAN_EXTENSION__) { - globalThis.__REACT_SCAN__ = { - ReactScanInternals, - }; - } - - const instrumentation = createInstrumentation('monitoring', { - onCommitStart() { - // ReactScanInternals.options.value.onCommitStart?.(); - }, - onError() { - // todo: report to server? - }, - isValidFiber() { - return true; - }, - onRender(fiber, renders) { - updateFiberRenderData(fiber, renders); - - if (isCompositeFiber(fiber)) { - aggregateComponentRenderToInteraction(fiber, renders); - } - // ReactScanInternals.options.value.onRender?.(fiber, renders); - }, - onCommitFinish() { - // ReactScanInternals.options.value.onCommitFinish?.(); - }, - onPostCommitFiberRoot() { - // ... - }, - trackChanges: false, - forceAlwaysTrackRenders: true, - }); - - ReactScanInternals.instrumentation = instrumentation; -}; - -const aggregateComponentRenderToInteraction = ( - fiber: Fiber, - renders: Array, -): void => { - const monitor = Store.monitor.value; - if (!monitor || !monitor.interactions || monitor.interactions.length === 0) - return; - const lastInteraction = monitor.interactions.at(-1); // Associate component render with last interaction - if (!lastInteraction) return; - - const displayName = getDisplayName(fiber.type); - if (!displayName) return; // TODO(nisarg): it may be useful to somehow report the first ancestor with a display name instead of completely ignoring - - let component = lastInteraction.components.get(displayName); // TODO(nisarg): Same names are grouped together which is wrong. - - if (!component) { - component = { - fibers: new Set(), - name: displayName, - renders: 0, - retiresAllowed: MAX_RETRIES_BEFORE_COMPONENT_GC, - uniqueInteractionId: lastInteraction.uniqueInteractionId, - }; - lastInteraction.components.set(displayName, component); - } - - if (fiber.alternate && !component.fibers.has(fiber.alternate)) { - // then the alternate tree fiber exists in the weakset, don't double count the instance - component.fibers.add(fiber.alternate); - } - - const rendersCount = renders.length; - component.renders += rendersCount; - - // We leave the times undefined to differentiate between a 0ms render and a non-profiled render. - if (fiber.actualDuration) { - const { selfTime, totalTime } = getTimings(fiber); - if (!component.totalTime) component.totalTime = 0; - if (!component.selfTime) component.selfTime = 0; - component.totalTime += totalTime; - component.selfTime += selfTime; - } -}; diff --git a/packages/scan/src/core/monitor/network.ts b/packages/scan/src/core/monitor/network.ts deleted file mode 100644 index fa484fe2..00000000 --- a/packages/scan/src/core/monitor/network.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Store } from '..'; -import { GZIP_MAX_LEN, GZIP_MIN_LEN, MAX_PENDING_REQUESTS } from './constants'; -import type { - Component, - IngestRequest, - Interaction, - InternalInteraction, -} from './types'; -import { getSession } from './utils'; - -const INTERACTION_TIME_TILL_COMPLETED = 4000; - -const truncate = (value: number, decimalPlaces = 4) => - Number(value.toFixed(decimalPlaces)); - -export const flush = async (): Promise => { - const monitor = Store.monitor.value; - if ( - !monitor || - !navigator.onLine || - !monitor.url || - !monitor.interactions.length - ) { - return; - } - const now = performance.now(); - // We might trigger flush before the interaction is completed, - // so we need to split them into pending and completed by an arbitrary time. - const pendingInteractions = new Array(); - const completedInteractions = new Array(); - - const interactions = monitor.interactions; - for (let i = 0; i < interactions.length; i++) { - const interaction = interactions[i]; - const timeSinceStart = now - interaction.performanceEntry.startTime; - if (timeSinceStart <= 30000) { - // Skip interactions older than 30 seconds to prevent memory leaks - if (timeSinceStart <= INTERACTION_TIME_TILL_COMPLETED) { - pendingInteractions.push(interaction); - } else { - completedInteractions.push(interaction); - } - } - } - - if (!completedInteractions.length) - // nothing to flush - return; - - // idempotent - const session = await getSession({ - commit: monitor.commit, - branch: monitor.branch, - }).catch(() => null); - - if (!session) return; - - const aggregatedComponents = new Array(); - const aggregatedInteractions = new Array(); - for (let i = 0; i < completedInteractions.length; i++) { - const interaction = completedInteractions[i]; - - // META INFORMATION IS FOR DEBUGGING THIS MUST BE REMOVED SOON - const { - duration, - entries, - id, - inputDelay, - latency, - presentationDelay, - processingDuration, - processingEnd, - processingStart, - referrer, - startTime, - timeOrigin, - timeSinceTabInactive, - timestamp, - type, - visibilityState, - } = interaction.performanceEntry; - aggregatedInteractions.push({ - id: i, - path: interaction.componentPath, - name: interaction.componentName, - time: truncate(duration), - timestamp, - type, - // fixme: we can aggregate around url|route|commit|branch better to compress payload - url: interaction.url, - route: interaction.route, - commit: interaction.commit, - branch: interaction.branch, - uniqueInteractionId: interaction.uniqueInteractionId, - meta: { - performanceEntry: { - id, - inputDelay: truncate(inputDelay), - latency: truncate(latency), - presentationDelay: truncate(presentationDelay), - processingDuration: truncate(processingDuration), - processingEnd, - processingStart, - referrer, - startTime, - timeOrigin, - timeSinceTabInactive, - visibilityState, - duration: truncate(duration), - entries: entries.map((entry) => { - const { - duration, - entryType, - interactionId, - name, - processingEnd, - processingStart, - startTime, - } = entry; - return { - duration: truncate(duration), - entryType, - interactionId, - name, - processingEnd, - processingStart, - startTime, - }; - }), - }, - }, - }); - - const components = Array.from(interaction.components.entries()); - for (let j = 0; j < components.length; j++) { - const [name, component] = components[j]; - aggregatedComponents.push({ - name, - instances: component.fibers.size, - interactionId: i, - renders: component.renders, - selfTime: - typeof component.selfTime === 'number' - ? truncate(component.selfTime) - : component.selfTime, - totalTime: - typeof component.totalTime === 'number' - ? truncate(component.totalTime) - : component.totalTime, - }); - } - } - - const payload: IngestRequest = { - interactions: aggregatedInteractions, - components: aggregatedComponents, - session: { - ...session, - url: window.location.toString(), - route: monitor.route, // this might be inaccurate but used to caculate which paths all the unique sessions are coming from without having to join on the interactions table (expensive) - wifi: session.wifi ?? '', - }, - }; - - monitor.pendingRequests++; - monitor.interactions = pendingInteractions; - try { - transport(monitor.url, payload) - .then(() => { - monitor.pendingRequests--; - // there may still be renders associated with these interaction, so don't flush just yet - }) - .catch(async () => { - // we let the next interval handle retrying, instead of explicitly retrying - monitor.interactions = monitor.interactions.concat( - completedInteractions, - ); - }); - } catch { - /* */ - } - - // Keep only recent interactions - monitor.interactions = pendingInteractions; -}; - -const CONTENT_TYPE = 'application/json'; -const supportsCompression = typeof CompressionStream === 'function'; - -export const compress = async (payload: string): Promise => { - const stream = new Blob([payload], { type: CONTENT_TYPE }) - .stream() - .pipeThrough(new CompressionStream('gzip')); - return new Response(stream).arrayBuffer(); -}; - -/** - * Modified from @palette.dev/browser: - * - * @see https://gist.github.com/aidenybai/473689493f2d5d01bbc52e2da5950b45#file-palette-dev-browser-dist-palette-dev-mjs-L365 - */ -interface RequestHeaders { - 'Content-Type': string; - 'Content-Encoding'?: string; - 'x-api-key'?: string; -} - -export const transport = async ( - initialUrl: string, - payload: IngestRequest, -): Promise<{ ok: boolean }> => { - const fail = { ok: false }; - const json = JSON.stringify(payload); - // gzip may not be worth it for small payloads, - // only use it if the payload is large enough - const shouldCompress = json.length > GZIP_MIN_LEN; - const body = - shouldCompress && supportsCompression ? await compress(json) : json; - - if (!navigator.onLine) return fail; - const headerValues: RequestHeaders = { - 'Content-Type': CONTENT_TYPE, - 'Content-Encoding': shouldCompress ? 'gzip' : undefined, - 'x-api-key': Store.monitor.value?.apiKey ?? undefined, - }; - let url = initialUrl; - if (shouldCompress) url += '?z=1'; - const size = typeof body === 'string' ? body.length : body.byteLength; - - return fetch(url, { - body, - method: 'POST', - referrerPolicy: 'origin', - /** - * Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to - * fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)". - * The `keepalive` flag keeps outgoing requests alive, even when switching pages. We want this since we're - * frequently sending events right before the user is switching pages (e.g., when finishing navigation transactions). - * - * This is the modern alternative to the navigator.sendBeacon API. - * @see https://javascript.info/fetch-api#keepalive - * - * Gotchas: - * - `keepalive` isn't supported by Firefox - * - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch): - * If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error. - * We will therefore only activate the flag when we're below that limit. - * - There is also a limit of requests that can be open at the same time, so we also limit this to 15. - * - * @see https://github.com/getsentry/sentry-javascript/pull/7553 - */ - keepalive: - GZIP_MAX_LEN > size && - MAX_PENDING_REQUESTS > (Store.monitor.value?.pendingRequests ?? 0), - priority: 'low', - // mode: 'no-cors', - headers: headerValues as unknown as HeadersInit, - }); -}; diff --git a/packages/scan/src/core/monitor/params/astro/Monitoring.astro b/packages/scan/src/core/monitor/params/astro/Monitoring.astro deleted file mode 100644 index 91e89ac6..00000000 --- a/packages/scan/src/core/monitor/params/astro/Monitoring.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -import type { MonitoringWithoutRouteProps } from '../..'; -// @ts-ignore This file will not be packaged, so the file to be imported should be a .mjs file. -import { AstroMonitor } from './component.mjs'; - -type Props = MonitoringWithoutRouteProps; - -const path = Astro.url.pathname; -const params = Astro.params; ---- - - diff --git a/packages/scan/src/core/monitor/params/astro/component.ts b/packages/scan/src/core/monitor/params/astro/component.ts deleted file mode 100644 index c7c79108..00000000 --- a/packages/scan/src/core/monitor/params/astro/component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createElement } from 'react'; -import { - Monitoring as BaseMonitoring, - type MonitoringWithoutRouteProps, -} from '../..'; -import { computeRoute } from '../utils'; - -export function AstroMonitor( - props: { - path: string; - params: Record; - } & MonitoringWithoutRouteProps, -) { - const path = props.path; - const route = computeRoute(path, props.params); - - return createElement(BaseMonitoring, { - ...props, - route, - path, - }); -} diff --git a/packages/scan/src/core/monitor/params/astro/index.ts b/packages/scan/src/core/monitor/params/astro/index.ts deleted file mode 100644 index 7a07d9ef..00000000 --- a/packages/scan/src/core/monitor/params/astro/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file will not be packaged - -export { default as Monitoring } from './Monitoring.astro'; diff --git a/packages/scan/src/core/monitor/params/next.ts b/packages/scan/src/core/monitor/params/next.ts deleted file mode 100644 index 475f570f..00000000 --- a/packages/scan/src/core/monitor/params/next.ts +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import { useParams, usePathname, useSearchParams } from 'next/navigation.js'; -import { createElement, Suspense } from 'react'; -import { - Monitoring as BaseMonitoring, - type MonitoringWithoutRouteProps, -} from '..'; -import { computeRoute } from './utils'; - -/** - * This hook works in both Next.js Pages and App Router: - * - App Router: Uses the new useParams() hook directly - * - Pages Router: useParams() returns empty object, falls back to searchParams - * This fallback behavior ensures compatibility across both routing systems - */ -const useRoute = (): { - route: string | null; - path: string; -} => { - const params = useParams(); - const searchParams = useSearchParams(); - const path = usePathname(); - - // Until we have route parameters, we don't compute the route - if (!params) { - return { route: null, path }; - } - // in Next.js@13, useParams() could return an empty object for pages router, and we default to searchParams. - const finalParams = Object.keys(params).length - ? (params as Record>) - : Object.fromEntries(searchParams.entries()); - return { route: computeRoute(path, finalParams), path }; -}; -export function MonitoringInner(props: MonitoringWithoutRouteProps) { - const { route, path } = useRoute(); - - // we need to fix build so this doesn't get compiled to preact jsx - return createElement(BaseMonitoring, { - ...props, - route, - path, - }); -} - -/** - * The double 'use client' directive pattern is intentional: - * 1. Top-level directive marks the entire module as client-side - * 2. IIFE-wrapped component with its own directive ensures: - * - Component is properly tree-shaken (via @__PURE__) - * - Component maintains client context when code-split - * - Execution scope is preserved - * - * This pattern is particularly important for Next.js's module - * system and its handling of Server/Client Components. - */ -export const Monitoring = /* @__PURE__ */ (() => { - 'use client'; - return function Monitoring(props: MonitoringWithoutRouteProps) { - return createElement( - Suspense, - { fallback: null }, - createElement(MonitoringInner, props), - ); - }; -})(); diff --git a/packages/scan/src/core/monitor/params/react-router-v5.ts b/packages/scan/src/core/monitor/params/react-router-v5.ts deleted file mode 100644 index 71b81206..00000000 --- a/packages/scan/src/core/monitor/params/react-router-v5.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createElement } from 'react'; -import { useRouteMatch, useLocation } from 'react-router'; -import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps} from '..'; -import { computeRoute } from './utils'; -import type { RouteInfo } from './types'; - -const useRoute = (): RouteInfo => { - const match = useRouteMatch(); - const { pathname } = useLocation(); - const params = match?.params || {}; - - if (!params) { - return { route: null, path: pathname }; - } - - return { - route: computeRoute(pathname, params), - path: pathname, - }; -}; - -function ReactRouterV5Monitor(props: MonitoringWithoutRouteProps) { - const { route, path } = useRoute(); - return createElement(BaseMonitoring, { - ...props, - route, - path, - }); -} - -export { ReactRouterV5Monitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/react-router-v6.ts b/packages/scan/src/core/monitor/params/react-router-v6.ts deleted file mode 100644 index d62f12f1..00000000 --- a/packages/scan/src/core/monitor/params/react-router-v6.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createElement } from 'react'; -import { useParams, useLocation } from 'react-router'; -import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps } from '..'; -import { computeReactRouterRoute } from './utils'; -import type { RouteInfo } from './types'; - -const useRoute = (): RouteInfo => { - const params = useParams(); - const { pathname } = useLocation(); - - if (!params || Object.keys(params).length === 0) { - return { route: null, path: pathname }; - } - - const validParams = Object.fromEntries( - Object.entries(params).filter(([_, v]) => v !== undefined), - ) as Record>; - - return { - route: computeReactRouterRoute(pathname, validParams), - path: pathname, - }; -}; - -function ReactRouterMonitor(props: MonitoringWithoutRouteProps) { - const { route, path } = useRoute(); - return createElement(BaseMonitoring, { - ...props, - route, - path, - }); -} - -export { ReactRouterMonitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/remix.ts b/packages/scan/src/core/monitor/params/remix.ts deleted file mode 100644 index e9ff727a..00000000 --- a/packages/scan/src/core/monitor/params/remix.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createElement } from 'react'; -import { useParams, useLocation } from '@remix-run/react'; -import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps} from '..'; -import { computeReactRouterRoute } from './utils'; -import type { RouteInfo } from './types'; - -const useRoute = (): RouteInfo => { - const params = useParams(); - const { pathname } = useLocation(); - - if (!params || Object.keys(params).length === 0) { - return { route: null, path: pathname }; - } - - const validParams = params as Record; - - return { - route: computeReactRouterRoute(pathname, validParams), - path: pathname, - }; -}; - -function RemixMonitor(props: MonitoringWithoutRouteProps) { - const { route, path } = useRoute(); - return createElement(BaseMonitoring, { - ...props, - route, - path, - }); -} - -export { RemixMonitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/types.ts b/packages/scan/src/core/monitor/params/types.ts deleted file mode 100644 index 25310a58..00000000 --- a/packages/scan/src/core/monitor/params/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RouteInfo { - route: string | null; - path: string; -} \ No newline at end of file diff --git a/packages/scan/src/core/monitor/params/utils.ts b/packages/scan/src/core/monitor/params/utils.ts deleted file mode 100644 index 399624da..00000000 --- a/packages/scan/src/core/monitor/params/utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -// adapted from vercel analytics https://github.dev/vercel/analytics -interface DynamicSegmentFormatter { - param: (key: string) => string; - catchAll: (key: string) => string; -} - -function computeRouteWithFormatter( - pathname: string | null, - pathParams: Record> | null, - formatter: DynamicSegmentFormatter, -): string | null { - if (!pathname || !pathParams) { - return pathname; - } - - let result = pathname; - try { - const entries = Object.entries(pathParams); - // simple keys must be handled first - for (const [key, value] of entries) { - if (!Array.isArray(value)) { - const matcher = turnValueToRegExp(value); - if (matcher.test(result)) { - result = result.replace(matcher, formatter.param(key)); - } - } - } - // array values next - for (const [key, value] of entries) { - if (Array.isArray(value)) { - const matcher = turnValueToRegExp(value.join('/')); - if (matcher.test(result)) { - result = result.replace(matcher, formatter.catchAll(key)); - } - } - } - return result; - } catch { - return pathname; - } -} - -// Next.js style routes (default) -export function computeRoute( - pathname: string | null, - pathParams: Record> | null, -): string | null { - return computeRouteWithFormatter(pathname, pathParams, { - param: (key) => `/[${key}]`, - catchAll: (key) => `/[...${key}]`, - }); -} - -export function computeReactRouterRoute( - pathname: string | null, - pathParams: Record> | null, -): string | null { - return computeRouteWithFormatter(pathname, pathParams, { - param: (key) => `/:${key}`, - catchAll: (key) => `/*${key}`, - }); -} - -export function turnValueToRegExp(value: string): RegExp { - return new RegExp(`/${escapeRegExp(value)}(?=[/?#]|$)`); -} - -export function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/packages/scan/src/core/monitor/performance.ts b/packages/scan/src/core/monitor/performance.ts deleted file mode 100644 index bbab127b..00000000 --- a/packages/scan/src/core/monitor/performance.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { type Fiber, getDisplayName } from 'bippy'; -import { getCompositeComponentFromElement } from '~web/views/inspector/utils'; -import { Store } from '..'; -import type { - PerformanceInteraction, - PerformanceInteractionEntry, -} from './types'; - -interface PathFilters { - skipProviders: boolean; - skipHocs: boolean; - skipContainers: boolean; - skipMinified: boolean; - skipUtilities: boolean; - skipBoundaries: boolean; -} - -const DEFAULT_FILTERS: PathFilters = { - skipProviders: true, - skipHocs: true, - skipContainers: true, - skipMinified: true, - skipUtilities: true, - skipBoundaries: true, -}; - -const FILTER_PATTERNS = { - providers: [/Provider$/, /^Provider$/, /^Context$/], - hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/], - containers: [/^(?:App)?Container$/, /^Root$/, /^ReactDev/], - utilities: [ - /^Fragment$/, - /^Suspense$/, - /^ErrorBoundary$/, - /^Portal$/, - /^Consumer$/, - /^Layout$/, - /^Router/, - /^Hydration/, - ], - boundaries: [/^Boundary$/, /Boundary$/, /^Provider$/, /Provider$/], -}; - -const shouldIncludeInPath = ( - name: string, - filters: PathFilters = DEFAULT_FILTERS, -): boolean => { - const patternsToCheck: Array = []; - if (filters.skipProviders) patternsToCheck.push(...FILTER_PATTERNS.providers); - if (filters.skipHocs) patternsToCheck.push(...FILTER_PATTERNS.hocs); - if (filters.skipContainers) - patternsToCheck.push(...FILTER_PATTERNS.containers); - if (filters.skipUtilities) patternsToCheck.push(...FILTER_PATTERNS.utilities); - if (filters.skipBoundaries) - patternsToCheck.push(...FILTER_PATTERNS.boundaries); - return !patternsToCheck.some((pattern) => pattern.test(name)); -}; - -const minifiedPatterns = [ - /^[a-z]$/, // Single lowercase letter - /^[a-z][0-9]$/, // Lowercase letter followed by number - /^_+$/, // Just underscores - /^[A-Za-z][_$]$/, // Letter followed by underscore or dollar - /^[a-z]{1,2}$/, // 1-2 lowercase letters -]; - -const isMinified = (name: string): boolean => { - for (let i = 0; i < minifiedPatterns.length; i++) { - if (minifiedPatterns[i].test(name)) return true; - } - - const hasNoVowels = !/[aeiou]/i.test(name); - const hasMostlyNumbers = (name.match(/\d/g)?.length ?? 0) > name.length / 2; - const isSingleWordLowerCase = /^[a-z]+$/.test(name); - const hasRandomLookingChars = /[$_]{2,}/.test(name); - - // If more than 2 of the following are true, we consider the name minified - return ( - Number(hasNoVowels) + - Number(hasMostlyNumbers) + - Number(isSingleWordLowerCase) + - Number(hasRandomLookingChars) >= - 2 - ); -}; - -export const getInteractionPath = ( - initialFiber: Fiber | null, - filters: PathFilters = DEFAULT_FILTERS, -): Array => { - if (!initialFiber) return []; - - const currentName = getDisplayName(initialFiber.type); - if (!currentName) return []; - - const stack = new Array(); - let fiber = initialFiber; - while (fiber.return) { - const name = getCleanComponentName(fiber.type); - if (name && !isMinified(name) && shouldIncludeInPath(name, filters) && name.toLowerCase() !== name) { - stack.push(name); - } - fiber = fiber.return; - } - const fullPath = new Array(stack.length); - for (let i = 0; i < stack.length; i++) { - fullPath[i] = stack[stack.length - i - 1]; - } - return fullPath; -}; - -let currentMouseOver: Element; - -interface FiberType { - displayName?: string; - name?: string; - [key: string]: unknown; -} - -const getCleanComponentName = (component: FiberType): string => { - const name = getDisplayName(component); - if (!name) return ''; - - return name.replace( - /^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/, - '$', - ); -}; - -// For future use, normalization of paths happens on server side now using path property of interaction -// const _normalizePath = (path: Array): string => { -// const cleaned = path.filter(Boolean); -// const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); -// return deduped.join('.'); -// }; - -const handleMouseover = (event: Event) => { - if (!(event.target instanceof Element)) return; - currentMouseOver = event.target; -}; - -const getFirstNamedAncestorCompositeFiber = (element: Element) => { - let curr: Element | null = element; - let parentCompositeFiber: Fiber | null = null; - while (!parentCompositeFiber && curr.parentElement) { - curr = curr.parentElement; - - const fiber = getCompositeComponentFromElement(curr).parentCompositeFiber; - - if (!fiber) { - continue; - } - if (getDisplayName(fiber.type)) { - parentCompositeFiber = fiber; - } - } - return parentCompositeFiber; -}; - -// fixme: compress me if this stays here for bad interaction time checks -let lastVisibilityHiddenAt: number | 'never-hidden' = 'never-hidden'; - -const onVisibilityChange = () => { - if (document.hidden) { - lastVisibilityHiddenAt = Date.now(); - } -}; - -const trackVisibilityChange = () => { - document.removeEventListener('visibilitychange', onVisibilityChange); - document.addEventListener('visibilitychange', onVisibilityChange); -}; - -// todo: update monitoring api to expose filters for component names -export function initPerformanceMonitoring(options?: Partial) { - const filters = { ...DEFAULT_FILTERS, ...options }; - const monitor = Store.monitor.value; - if (!monitor) return; - - document.addEventListener('mouseover', handleMouseover); - const disconnectPerformanceListener = setupPerformanceListener((entry) => { - const target = - entry.target ?? (entry.type === 'pointer' ? currentMouseOver : null); - if (!target) { - // most likely an invariant that we should log if its violated - return; - } - const parentCompositeFiber = getFirstNamedAncestorCompositeFiber(target); - if (!parentCompositeFiber) { - return; - } - const displayName = getDisplayName(parentCompositeFiber.type); - if (!displayName || isMinified(displayName)) { - // invariant, we know its named based on getFirstNamedAncestorCompositeFiber implementation - return; - } - - const path = getInteractionPath(parentCompositeFiber, filters); - - monitor.interactions.push({ - componentName: displayName, - componentPath: path, - performanceEntry: entry, - components: new Map(), - url: window.location.toString(), - route: - Store.monitor.value?.route ?? new URL(window.location.href).pathname, - commit: Store.monitor.value?.commit ?? null, - branch: Store.monitor.value?.branch ?? null, - uniqueInteractionId: entry.id, - }); - }); - - return () => { - disconnectPerformanceListener(); - document.removeEventListener('mouseover', handleMouseover); - }; -} - -const POINTER_EVENTS = new Set(['pointerdown', 'pointerup', 'click']); -const KEYBOARD_EVENTS = new Set(['keydown', 'keyup']); - -const getInteractionType = ( - eventName: string, -): 'pointer' | 'keyboard' | null => { - if (POINTER_EVENTS.has(eventName)) { - return 'pointer'; - } - if (KEYBOARD_EVENTS.has(eventName)) { - return 'keyboard'; - } - return null; -}; - -const setupPerformanceListener = ( - onEntry: (interaction: PerformanceInteraction) => void, -) => { - trackVisibilityChange(); - const longestInteractionMap = new Map(); - const interactionTargetMap = new Map(); - - const processInteractionEntry = (entry: PerformanceInteractionEntry) => { - if (!(entry.interactionId || entry.entryType === 'first-input')) return; - - if ( - entry.interactionId && - entry.target && - !interactionTargetMap.has(entry.interactionId) - ) { - interactionTargetMap.set(entry.interactionId, entry.target); - } - - const existingInteraction = longestInteractionMap.get(entry.interactionId); - - if (existingInteraction) { - if (entry.duration > existingInteraction.latency) { - existingInteraction.entries = [entry]; - existingInteraction.latency = entry.duration; - } else if ( - entry.duration === existingInteraction.latency && - entry.startTime === existingInteraction.entries[0].startTime - ) { - existingInteraction.entries.push(entry); - } - } else { - const interactionType = getInteractionType(entry.name); - if (!interactionType) return; - - const interaction: PerformanceInteraction = { - id: entry.interactionId, - latency: entry.duration, - entries: [entry], - target: entry.target, - type: interactionType, - startTime: entry.startTime, - processingStart: entry.processingStart, - processingEnd: entry.processingEnd, - duration: entry.duration, - inputDelay: entry.processingStart - entry.startTime, - processingDuration: entry.processingEnd - entry.processingStart, - presentationDelay: - entry.duration - (entry.processingEnd - entry.startTime), - timestamp: Date.now(), - timeSinceTabInactive: - lastVisibilityHiddenAt === 'never-hidden' - ? 'never-hidden' - : Date.now() - lastVisibilityHiddenAt, - visibilityState: document.visibilityState, - timeOrigin: performance.timeOrigin, - referrer: document.referrer, - }; - longestInteractionMap.set(interaction.id, interaction); - - onEntry(interaction); - } - }; - - const po = new PerformanceObserver((list) => { - const entries = list.getEntries(); - for (let i = 0, len = entries.length; i < len; i++) { - const entry = entries[i]; - processInteractionEntry(entry as PerformanceInteractionEntry); - } - }); - - try { - po.observe({ - type: 'event', - buffered: true, - durationThreshold: 16, - } as PerformanceObserverInit); - po.observe({ - type: 'first-input', - buffered: true, - }); - } catch { - /* Should collect error logs*/ - } - - return po.disconnect.bind(po); -}; diff --git a/packages/scan/src/core/monitor/types.ts b/packages/scan/src/core/monitor/types.ts deleted file mode 100644 index 9ae875f2..00000000 --- a/packages/scan/src/core/monitor/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Fiber } from 'bippy'; - -export enum Device { - DESKTOP = 0, - TABLET = 1, - MOBILE = 2, -} - -export interface Session { - id: string; - device: Device; - agent: string; - wifi: string; - cpu: number; - gpu: string | null; - mem: number; - url: string; - route: string | null; - commit: string | null; - branch: string | null; -} - -export interface Interaction { - id: string | number; // index of the interaction in the batch at ingest | server converts to a hashed string from route, type, name, path - path: Array; // the path of the interaction - name: string; // name of interaction - type: string; // type of interaction i.e pointer - time: number; // time of interaction in ms - timestamp: number; - url: string; - route: string | null; // the computed route that handles dynamic params - - // Regression tracking - commit: string | null; - branch: string | null; - - // clickhouse + ingest specific types - projectId?: string; - sessionId?: string; - uniqueInteractionId: string; - - meta?: unknown; -} - -export interface Component { - interactionId: string | number; // grouping components by interaction - name: string; - renders: number; // how many times it re-rendered / instances (normalized) - instances: number; // instances which will be used to get number of total renders by * by renders - totalTime?: number; - selfTime?: number; -} - -export interface IngestRequest { - interactions: Array; - components: Array; - session: Session; -} - -// used internally in runtime for interaction tracking. converted to Interaction when flushed -export interface InternalInteraction { - componentName: string; - url: string; - route: string | null; - commit: string | null; - branch: string | null; - uniqueInteractionId: string; // uniqueInteractionId is unique to the session and provided by performance observer. - componentPath: Array; - performanceEntry: PerformanceInteraction; - components: Map; -} -interface InternalComponentCollection { - uniqueInteractionId: string; - name: string; - renders: number; // re-renders associated with the set of components in this collection - totalTime?: number; - selfTime?: number; - fibers: Set; // no references will exist to this once array is cleared after flush, so we don't have to worry about memory leaks - retiresAllowed: number; // if our server is down and we can't collect fibers/ user has no network, it will memory leak. We need to only allow a set amount of retries before it gets gcd -} - -export interface PerformanceInteractionEntry extends PerformanceEntry { - interactionId: string; - target: Element; - name: string; - duration: number; - startTime: number; - processingStart: number; - processingEnd: number; - entryType: string; -} -export interface PerformanceInteraction { - id: string; - latency: number; - entries: Array; - target: Element; - type: 'pointer' | 'keyboard'; - startTime: number; - processingStart: number; - processingEnd: number; - duration: number; - inputDelay: number; - processingDuration: number; - presentationDelay: number; - timestamp: number; - timeSinceTabInactive: number | 'never-hidden'; - visibilityState: DocumentVisibilityState; - timeOrigin: number; - referrer: string; -} diff --git a/packages/scan/src/core/monitor/utils.ts b/packages/scan/src/core/monitor/utils.ts deleted file mode 100644 index 0234d234..00000000 --- a/packages/scan/src/core/monitor/utils.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { IS_CLIENT } from '~web/utils/constants'; -import { onIdle } from '~web/utils/helpers'; -import { isSSR } from './constants'; -import { Device, type Session } from './types'; - -interface NetworkInformation { - connection?: { - effectiveType?: string; - }; -} - -interface ExtendedNavigator extends Navigator { - deviceMemory?: number; -} - -const MOBILE_PATTERN = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; - -const TABLET_PATTERN = /iPad|Tablet/i; - -const getDeviceType = () => { - const userAgent = navigator.userAgent; - - if (MOBILE_PATTERN.test(userAgent)) { - return Device.MOBILE; - } - if (TABLET_PATTERN.test(userAgent)) { - return Device.TABLET; - } - return Device.DESKTOP; -}; - -/** - * Measure layout time - */ -export const doubleRAF = (callback: (...args: unknown[]) => void) => { - return requestAnimationFrame(requestAnimationFrame.bind(window, callback)); -}; - -export const generateId = () => { - const alphabet = - 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'; - let id = ''; - const randomValues = crypto.getRandomValues(new Uint8Array(21)); - for (let i = 0; i < 21; i++) { - id += alphabet[63 & randomValues[i]]; - } - return id; -}; - -/** - * @see https://deviceandbrowserinfo.com/learning_zone/articles/webgl_renderer_values - */ -const getGpuRenderer = () => { - if (!('chrome' in window)) return ''; // Prevent WEBGL_debug_renderer_info deprecation warnings in firefox - const gl = document - .createElement('canvas') - - // Get the specs for the fastest GPU available. This helps provide a better - // picture of the device's capabilities. - .getContext('webgl', { powerPreference: 'high-performance' }); - if (!gl) return ''; - const ext = gl.getExtension('WEBGL_debug_renderer_info'); - return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ''; -}; - -/** - * Session is a loose way to fingerprint / identify a session. - * - * Modified from @palette.dev/browser: - * @see https://gist.github.com/aidenybai/473689493f2d5d01bbc52e2da5950b45#file-palette-dev-browser-dist-palette-dev-mjs-L554 - * DO NOT CALL THIS EVERYTIME - */ -let cachedSession: Session; -export const getSession = async ({ - commit = null, - branch = null, -}: { - commit?: string | null; - branch?: string | null; -}) => { - if (isSSR()) return null; - if (cachedSession) { - return cachedSession; - } - const id = generateId(); - const url = window.location.toString(); - /** - * WiFi connection strength - * - * Potential outputs: slow-2g, 2g, 3g, 4g - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType - */ - const connection = (navigator as NetworkInformation).connection; - const wifi = connection?.effectiveType ?? null; - /** - * Number of CPU threads - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/hardwareConcurrency - */ - const cpu = navigator.hardwareConcurrency; - /** - * Device memory (GiB) - * - * Potential outputs: 0.25, 0.5, 1, 2, 4, 8 - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory - */ - const mem = (navigator as ExtendedNavigator).deviceMemory ?? 0; - - const gpuRendererPromise = new Promise((resolve) => { - onIdle(() => { - resolve(getGpuRenderer()); - }); - }); - - const session = { - id, - url, - route: null, - device: getDeviceType(), - wifi: wifi ?? '', - cpu, - mem, - gpu: await gpuRendererPromise, - agent: navigator.userAgent, - commit, - branch, - version: process.env.NPM_PACKAGE_VERSION, - }; - cachedSession = session; - return session; -}; - -export const not_globally_unique_generateId = () => { - if (!IS_CLIENT) { - return '0'; - } - - // @ts-expect-error - if (window.reactScanIdCounter === undefined) { - // @ts-expect-error - window.reactScanIdCounter = 0; - } - // @ts-expect-error - return `${++window.reactScanIdCounter}`; -}; diff --git a/packages/scan/src/core/notifications/event-tracking.ts b/packages/scan/src/core/notifications/event-tracking.ts index b708507c..371aed83 100644 --- a/packages/scan/src/core/notifications/event-tracking.ts +++ b/packages/scan/src/core/notifications/event-tracking.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'preact/compat'; -import { not_globally_unique_generateId } from '~core/monitor/utils'; +import { not_globally_unique_generateId } from '~core/utils'; import { MAX_INTERACTION_BATCH, interactionStore } from './interaction-store'; import { FiberRenders, @@ -123,7 +123,7 @@ export const debugEventStore = createStore<{ events: Array; }; actions: { - // biome-ignore lint/suspicious/noExplicitAny: debug only store + // oxlint-disable-next-line typescript/no-explicit-any addEvent: (event: any) => void; clear: () => void; }; @@ -374,7 +374,7 @@ export function startLongPipelineTracking() { endAt: endAt, startAt: startAt, meta: { - // biome-ignore lint/style/noNonNullAssertion: invariant: this will exist by this point + // oxlint-disable-next-line typescript/no-non-null-assertion fiberRenders: accumulatedFiberRendersOverTask!, latency: duration, fps, diff --git a/packages/scan/src/core/notifications/outline-overlay.ts b/packages/scan/src/core/notifications/outline-overlay.ts index 0cd79154..7703128c 100644 --- a/packages/scan/src/core/notifications/outline-overlay.ts +++ b/packages/scan/src/core/notifications/outline-overlay.ts @@ -1,18 +1,12 @@ import { signal } from '@preact/signals'; import { iife } from './performance-utils'; -export interface HeatmapOverlay { - boundingRect: DOMRect; - ms: number; - name: string; -} - export let highlightCanvas: HTMLCanvasElement | null = null; export let highlightCtx: CanvasRenderingContext2D | null = null; let animationFrame: number | null = null; -export type TransitionHighlightState = { +type TransitionHighlightState = { kind: 'transition'; transitionTo: { name: string; @@ -49,15 +43,26 @@ export const HighlightStore = signal({ }); let currFrame: ReturnType | null = null; +let lastFrameTime = 0; +const FADE_SPEED = 1.8; +const MAX_DELTA = 0.05; +const DEFAULT_DELTA = 1 / 60; + export const drawHighlights = () => { if (currFrame) { cancelAnimationFrame(currFrame); } - currFrame = requestAnimationFrame(() => { + currFrame = requestAnimationFrame((timestamp) => { if (!highlightCanvas || !highlightCtx) { return; } + const dt = lastFrameTime + ? Math.min((timestamp - lastFrameTime) / 1000, MAX_DELTA) + : DEFAULT_DELTA; + lastFrameTime = timestamp; + const step = FADE_SPEED * dt; + highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); const color = 'hsl(271, 76%, 53%)'; @@ -114,18 +119,19 @@ export const drawHighlights = () => { kind: 'idle', current: null, }; + lastFrameTime = 0; return; } if (state.current.alpha <= 0.01) { state.current.alpha = 0; } - state.current.alpha = Math.max(0, state.current.alpha - 0.03); + state.current.alpha = Math.max(0, state.current.alpha - step); drawHighlights(); return; } case 'transition': { if (state.current && state.current.alpha > 0) { - state.current.alpha = Math.max(0, state.current.alpha - 0.03); + state.current.alpha = Math.max(0, state.current.alpha - step); drawHighlights(); return; } @@ -136,16 +142,17 @@ export const drawHighlights = () => { kind: 'idle', current: state.transitionTo, }; + lastFrameTime = 0; return; } - // intentionally linear - state.transitionTo.alpha = Math.min(state.transitionTo.alpha + 0.03, 1); + state.transitionTo.alpha = Math.min(state.transitionTo.alpha + step, 1); drawHighlights(); } case 'idle': { // no-op + lastFrameTime = 0; return; } } diff --git a/packages/scan/src/core/notifications/performance-utils.ts b/packages/scan/src/core/notifications/performance-utils.ts index eee24e08..228c5c98 100644 --- a/packages/scan/src/core/notifications/performance-utils.ts +++ b/packages/scan/src/core/notifications/performance-utils.ts @@ -36,7 +36,7 @@ export const createChildrenAdjacencyList = (root: Fiber, limit: number) => { if (traversed >= limit) { return tree; } - // biome-ignore lint/style/noNonNullAssertion: invariant + // oxlint-disable-next-line typescript/no-non-null-assertion const [node, parent] = queue.pop()!; const children = getChildrenFromFiberLL(node); @@ -61,29 +61,6 @@ export const createChildrenAdjacencyList = (root: Fiber, limit: number) => { return tree; }; -const isProduction: boolean = process.env.NODE_ENV === 'production'; -const prefix: string = 'Invariant failed'; - -// FIX ME THIS IS PRODUCTION INVARIANT LOL -export function devInvariant( - condition: unknown, - message?: string | (() => string), -): asserts condition { - if (condition) { - return; - } - - if (isProduction) { - throw new Error(prefix); - } - - const provided: string | undefined = - typeof message === 'function' ? message() : message; - - const value: string = provided ? `${prefix}: ${provided}` : prefix; - throw new Error(value); -} - const THROW_INVARIANTS = false; export const invariantError = (message: string | undefined) => { diff --git a/packages/scan/src/core/notifications/performance.ts b/packages/scan/src/core/notifications/performance.ts index 4fe86cf1..86259e4b 100644 --- a/packages/scan/src/core/notifications/performance.ts +++ b/packages/scan/src/core/notifications/performance.ts @@ -10,7 +10,6 @@ import { Store } from '../..'; import { BoundedArray, - createChildrenAdjacencyList, invariantError, } from '~core/notifications/performance-utils'; import { @@ -26,8 +25,120 @@ import type { PerformanceInteraction, PerformanceInteractionEntry, } from './types'; -import { not_globally_unique_generateId } from '~core/monitor/utils'; -import { getInteractionPath } from '~core/monitor/performance'; +import { not_globally_unique_generateId } from '~core/utils'; + +interface PathFilters { + skipProviders: boolean; + skipHocs: boolean; + skipContainers: boolean; + skipMinified: boolean; + skipUtilities: boolean; + skipBoundaries: boolean; +} + +const DEFAULT_PATH_FILTERS: PathFilters = { + skipProviders: true, + skipHocs: true, + skipContainers: true, + skipMinified: true, + skipUtilities: true, + skipBoundaries: true, +}; + +const PATH_FILTER_PATTERNS = { + providers: [/Provider$/, /^Provider$/, /^Context$/], + hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/], + containers: [/^(?:App)?Container$/, /^Root$/, /^ReactDev/], + utilities: [ + /^Fragment$/, + /^Suspense$/, + /^ErrorBoundary$/, + /^Portal$/, + /^Consumer$/, + /^Layout$/, + /^Router/, + /^Hydration/, + ], + boundaries: [/^Boundary$/, /Boundary$/, /^Provider$/, /Provider$/], +}; + +const shouldIncludeInPath = ( + name: string, + filters: PathFilters = DEFAULT_PATH_FILTERS, +): boolean => { + const patternsToCheck: Array = []; + if (filters.skipProviders) patternsToCheck.push(...PATH_FILTER_PATTERNS.providers); + if (filters.skipHocs) patternsToCheck.push(...PATH_FILTER_PATTERNS.hocs); + if (filters.skipContainers) patternsToCheck.push(...PATH_FILTER_PATTERNS.containers); + if (filters.skipUtilities) patternsToCheck.push(...PATH_FILTER_PATTERNS.utilities); + if (filters.skipBoundaries) patternsToCheck.push(...PATH_FILTER_PATTERNS.boundaries); + return !patternsToCheck.some((pattern) => pattern.test(name)); +}; + +const minifiedPatterns = [ + /^[a-z]$/, + /^[a-z][0-9]$/, + /^_+$/, + /^[A-Za-z][_$]$/, + /^[a-z]{1,2}$/, +]; + +const isMinified = (name: string): boolean => { + for (let i = 0; i < minifiedPatterns.length; i++) { + if (minifiedPatterns[i].test(name)) return true; + } + const hasNoVowels = !/[aeiou]/i.test(name); + const hasMostlyNumbers = (name.match(/\d/g)?.length ?? 0) > name.length / 2; + const isSingleWordLowerCase = /^[a-z]+$/.test(name); + const hasRandomLookingChars = /[$_]{2,}/.test(name); + return ( + Number(hasNoVowels) + + Number(hasMostlyNumbers) + + Number(isSingleWordLowerCase) + + Number(hasRandomLookingChars) >= + 2 + ); +}; + +interface FiberType { + displayName?: string; + name?: string; + [key: string]: unknown; +} + +const getCleanComponentName = (component: FiberType): string => { + const name = getDisplayName(component); + if (!name) return ''; + return name.replace( + /^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/, + '$', + ); +}; + +const getInteractionPath = ( + initialFiber: Fiber | null, + filters: PathFilters = DEFAULT_PATH_FILTERS, +): Array => { + if (!initialFiber) return []; + + const currentName = getDisplayName(initialFiber.type); + if (!currentName) return []; + + const stack = new Array(); + let fiber = initialFiber; + while (fiber.return) { + const name = getCleanComponentName(fiber.type); + if (name && !isMinified(name) && shouldIncludeInPath(name, filters) && name.toLowerCase() !== name) { + stack.push(name); + } + fiber = fiber.return; + } + const fullPath = new Array(stack.length); + for (let i = 0; i < stack.length; i++) { + fullPath[i] = stack[stack.length - i - 1]; + } + return fullPath; +}; const getFirstNameFromAncestor = ( fiber: Fiber, @@ -176,15 +287,6 @@ type CurrentInteraction = { }; export let currentInteractions: Array = []; -export const fastHash = (str: string): string => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return hash.toString(36); -}; const getInteractionType = ( eventName: string, ): 'pointer' | 'keyboard' | null => { @@ -199,17 +301,6 @@ const getInteractionType = ( } return null; }; -// biome-ignore lint/suspicious/noExplicitAny: shut up biome -export const getInteractionId = (interaction: any) => { - return `${interaction.performanceEntry.type}::${normalizePath(interaction.componentPath)}::${interaction.url}`; -}; -export function normalizePath(path: string[]): string { - const cleaned = path.filter(Boolean); - - const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); - - return deduped.join('.'); -} let onEntryAnimationId: number | null = null; const setupPerformanceListener = ( onEntry: (interaction: PerformanceInteraction) => void, @@ -299,7 +390,7 @@ const setupPerformanceListener = ( if (!onEntryAnimationId) { onEntryAnimationId = requestAnimationFrame(() => { requestAnimationFrame(() => { - // biome-ignore lint/style/noNonNullAssertion: invariant + // oxlint-disable-next-line typescript/no-non-null-assertion onEntry(interactionMap.get(interaction.id)!); onEntryAnimationId = null; }); @@ -598,7 +689,7 @@ export const setupDetailedPointerTimingListener = ( }; const event = getEvent({ phase: 'end', target: e.target as Element }); - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any document.addEventListener(event, onLastJS as any, { once: true, }); @@ -608,14 +699,14 @@ export const setupDetailedPointerTimingListener = ( // it causes the event handler to stay alive until a future interaction, which can break timing (looks super long) // or invariants (the start metadata was removed, so now its an end metadata with no start) requestAnimationFrame(() => { - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any document.removeEventListener(event as any, onLastJS as any); }); }; document.addEventListener( getEvent({ phase: 'start' }), - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any onInteractionStart as any, { capture: true, @@ -799,55 +890,24 @@ export const setupDetailedPointerTimingListener = ( }; if (kind === 'keyboard') { - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any document.addEventListener('keypress', onKeyPress as any); } return () => { document.removeEventListener( getEvent({ phase: 'start' }), - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any onInteractionStart as any, { capture: true, }, ); - // biome-ignore lint/suspicious/noExplicitAny: shut up biome + // oxlint-disable-next-line typescript/no-explicit-any document.removeEventListener('keypress', onKeyPress as any); }; }; -// unused, but will be soon for monitoring -export const collectFiberSubtree = ( - fiber: Fiber, - limit: number, -): Record< - string, - { - children: Array; - firstNamedAncestor: string; - isRoot: boolean; - isSvg: boolean; - } -> => { - const adjacencyList = createChildrenAdjacencyList(fiber, limit).entries(); - const fiberToNames = Array.from(adjacencyList).map( - ([fiber, { children, parent, isRoot, isSVG }]) => [ - getDisplayName(fiber.type) ?? 'N/A', - { - children: children.map((fiber) => getDisplayName(fiber.type) ?? 'N/A'), - firstNamedAncestor: parent - ? (getFirstNameFromAncestor(parent) ?? 'No Parent') - : 'No Parent', - isRoot, - isSVG, - }, - ], - ); - - return Object.fromEntries(fiberToNames); -}; - const getHostFromFiber = (fiber: Fiber) => { return traverseFiber(fiber, (node) => { // shouldn't be too slow diff --git a/packages/scan/src/core/utils.ts b/packages/scan/src/core/utils.ts index ba8e119f..3a4f11c1 100644 --- a/packages/scan/src/core/utils.ts +++ b/packages/scan/src/core/utils.ts @@ -1,9 +1,8 @@ // @ts-nocheck import { type Fiber, getType } from 'bippy'; -// import type { ComponentType } from 'preact'; import { ReactScanInternals } from '~core/index'; -import type { AggregatedRender } from '~web/utils/outline'; -import type { AggregatedChange, Render } from './instrumentation'; +import type { AggregatedChange, AggregatedRender, Render } from './instrumentation'; +import { IS_CLIENT } from '~web/utils/constants'; export const aggregateChanges = ( changes: Array, @@ -21,25 +20,6 @@ export const aggregateChanges = ( return newChange; }; -export const joinAggregations = ({ - from, - to, -}: { - from: AggregatedRender; - to: AggregatedRender; -}) => { - to.changes.type |= from.changes.type; - to.changes.unstable = to.changes.unstable || from.changes.unstable; - to.aggregatedCount += 1; - to.didCommit = to.didCommit || from.didCommit; - to.forget = to.forget || from.forget; - to.fps = to.fps + from.fps; - to.phase |= from.phase; - to.time = (to.time ?? 0) + (from.time ?? 0); - - to.unnecessary = to.unnecessary || from.unnecessary; -}; - export const aggregateRender = ( newRender: Render, prevAggregated: AggregatedRender, @@ -196,6 +176,20 @@ export function isEqual(a: unknown, b: unknown): boolean { return a === b || (a !== a && b !== b); } +export const not_globally_unique_generateId = () => { + if (!IS_CLIENT) { + return '0'; + } + + // @ts-expect-error + if (window.reactScanIdCounter === undefined) { + // @ts-expect-error + window.reactScanIdCounter = 0; + } + // @ts-expect-error + return `${++window.reactScanIdCounter}`; +}; + export const playNotificationSound = (audioContext: AudioContext) => { const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); diff --git a/packages/scan/src/new-outlines/canvas.ts b/packages/scan/src/new-outlines/canvas.ts index 78100b38..30d21c28 100644 --- a/packages/scan/src/new-outlines/canvas.ts +++ b/packages/scan/src/new-outlines/canvas.ts @@ -4,9 +4,12 @@ export const OUTLINE_ARRAY_SIZE = 7; const MONO_FONT = 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; -const INTERPOLATION_SPEED = 0.1; +const INTERPOLATION_SPEED = 0.2; +const SNAP_THRESHOLD = 0.5; const lerp = (start: number, end: number) => { - return Math.floor(start + (end - start) * INTERPOLATION_SPEED); + const delta = end - start; + if (Math.abs(delta) < SNAP_THRESHOLD) return end; + return start + delta * INTERPOLATION_SPEED; }; const MAX_PARTS_LENGTH = 4; @@ -14,7 +17,6 @@ const MAX_LABEL_LENGTH = 40; const TOTAL_FRAMES = 45; const PRIMARY_COLOR = '115,97,230'; -// const SECONDARY_COLOR = '128,128,128'; function sortEntry(prev: [number, string[]], next: [number, string[]]): number { return next[0] - prev[0]; @@ -212,8 +214,14 @@ export const drawCanvas = ( ctx.strokeStyle = `rgba(${PRIMARY_COLOR},${alpha})`; ctx.lineWidth = 1; + // Offset by 0.5px for crisp 1px strokes on pixel boundaries + const rx = Math.round(x) + 0.5; + const ry = Math.round(y) + 0.5; + const rw = Math.round(width); + const rh = Math.round(height); + ctx.beginPath(); - ctx.rect(x, y, width, height); + ctx.rect(rx, ry, rw, rh); ctx.stroke(); ctx.fillStyle = `rgba(${PRIMARY_COLOR},${alpha * 0.1})`; ctx.fill(); diff --git a/packages/scan/src/new-outlines/index.ts b/packages/scan/src/new-outlines/index.ts index bb3ed3f5..974a9d19 100644 --- a/packages/scan/src/new-outlines/index.ts +++ b/packages/scan/src/new-outlines/index.ts @@ -344,7 +344,7 @@ export const getCanvasEl = () => { [offscreenCanvas], ); } catch (e) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.warn('Failed to initialize OffscreenCanvas worker:', e); } } diff --git a/packages/scan/src/react-component-name/astro.ts b/packages/scan/src/react-component-name/astro.ts index 7117e7ed..f8ca1cef 100644 --- a/packages/scan/src/react-component-name/astro.ts +++ b/packages/scan/src/react-component-name/astro.ts @@ -4,7 +4,7 @@ import vite from './vite'; export default (options: Options = {}) => ({ name: 'react-component-name', hooks: { - // biome-ignore lint/suspicious/noExplicitAny: should be { config: AstroConfig } + // oxlint-disable-next-line typescript/no-explicit-any 'astro:config:setup': (astro: any) => { astro.config.vite.plugins ||= []; astro.config.vite.plugins.push(vite(options)); diff --git a/packages/scan/src/react-component-name/index.ts b/packages/scan/src/react-component-name/index.ts index 4e798d5b..b7fffd26 100644 --- a/packages/scan/src/react-component-name/index.ts +++ b/packages/scan/src/react-component-name/index.ts @@ -40,7 +40,7 @@ export const transform = async ( return null; } catch (error) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.error('Error processing file:', id, error); return null; } diff --git a/packages/scan/src/web/assets/css/styles.tailwind.css b/packages/scan/src/web/assets/css/styles.tailwind.css index dfa375c8..992e5491 100644 --- a/packages/scan/src/web/assets/css/styles.tailwind.css +++ b/packages/scan/src/web/assets/css/styles.tailwind.css @@ -7,7 +7,6 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - backface-visibility: hidden; /* WebKit (Chrome, Safari, Edge) specific scrollbar styles */ &::-webkit-scrollbar { @@ -46,7 +45,7 @@ button { @apply hover:bg-none; @apply outline-none; @apply border-none; - @apply transition-colors ease-linear; + @apply transition-colors ease-out; @apply cursor-pointer; } @@ -54,7 +53,6 @@ input { @apply outline-none; @apply border-none; @apply bg-none bg-transparent; - @apply outline-none; @apply placeholder:text-neutral-500 placeholder:italic placeholder:text-xs; @apply placeholder-shown:truncate; } @@ -105,21 +103,8 @@ svg { @apply shadow-[0_4px_12px_rgba(0,0,0,0.2)]; @apply place-self-start; - /* [CURSOR GENERATED] Anti-blur fixes: - * We removed will-change-transform and replaced it with these properties - * because will-change was causing stacking context issues and inconsistent - * text rendering. The new properties work together to force proper - * GPU acceleration without z-index side effects: - */ - transform: translate3d( - 0, - 0, - 0 - ); /* Forces GPU acceleration without causing stacking issues */ - backface-visibility: hidden; /* Prevents blurry text during transforms */ - perspective: 1000; /* Creates proper 3D context for crisp text */ - -webkit-transform-style: preserve-3d; /* Ensures consistent text rendering across browsers */ - transform-style: preserve-3d; + will-change: transform; + backface-visibility: hidden; } .button { @@ -374,16 +359,6 @@ svg { } } -.react-scan-expandable { - @apply grid grid-rows-[0fr]; - @apply transition-all duration-75; - - &.react-scan-expanded { - @apply grid-rows-[1fr]; - @apply duration-100; - } -} - .react-scan-nested { @apply relative; @apply overflow-hidden; @@ -476,7 +451,8 @@ svg { .react-scan-inspector-overlay { @apply flex flex-col; @apply opacity-0; - @apply transition-opacity duration-300; + @apply transition-opacity duration-200 ease-out; + will-change: opacity; &.fade-out { @apply opacity-0; @@ -504,7 +480,7 @@ svg { .count-badge { @apply flex gap-x-2 items-center; @apply px-1.5 py-0.5; - @apply text-[#a855f7] text-xs font-medium tabular-nums rounded-[4px] origin-center; + @apply text-[#a855f7] text-xs font-medium tabular-nums rounded-[4px]; @apply bg-[#a855f7]/10; @apply origin-center; @apply transition-all duration-300 delay-150; @@ -526,7 +502,7 @@ svg { > div { @apply px-1.5 py-0.5; - @apply text-xs font-medium tabular-nums rounded-[4px] origin-center; + @apply text-xs font-medium tabular-nums rounded-[4px]; @apply origin-center; @apply transition-all duration-300 delay-150; diff --git a/packages/scan/src/web/components/slider/index.tsx b/packages/scan/src/web/components/slider/index.tsx index 31e0d7f1..bd281fad 100644 --- a/packages/scan/src/web/components/slider/index.tsx +++ b/packages/scan/src/web/components/slider/index.tsx @@ -32,7 +32,7 @@ export const Slider = ({ }, [min, max]); /** - * biome-ignore lint/correctness/useExhaustiveDependencies: + * oxlint-disable-next-line react-hooks/exhaustive-deps * we rely on min, max and value to update the thumb position */ useEffect(() => { diff --git a/packages/scan/src/web/hooks/use-delayed-value.ts b/packages/scan/src/web/hooks/use-delayed-value.ts index f63a4b4e..1306a354 100644 --- a/packages/scan/src/web/hooks/use-delayed-value.ts +++ b/packages/scan/src/web/hooks/use-delayed-value.ts @@ -40,7 +40,7 @@ export const useDelayedValue = ( const [delayedValue, setDelayedValue] = useState(value); /* - * biome-ignore lint/correctness/useExhaustiveDependencies: + * oxlint-disable-next-line react-hooks/exhaustive-deps * delayedValue is intentionally omitted to prevent unnecessary timeouts * and used only in the early return check */ diff --git a/packages/scan/src/web/utils/create-store.ts b/packages/scan/src/web/utils/create-store.ts index 1c4b3440..8f85a020 100644 --- a/packages/scan/src/web/utils/create-store.ts +++ b/packages/scan/src/web/utils/create-store.ts @@ -49,7 +49,7 @@ export type StateCreator< store: Mutate, Mis>, ) => U) & { $$storeMutators?: Mos }; -// biome-ignore lint/correctness/noUnusedVariables: +// oxlint-disable-next-line no-unused-vars export interface StoreMutators {} export type StoreMutatorIdentifier = keyof StoreMutators; @@ -99,19 +99,19 @@ const createStoreImpl: CreateStoreImpl = (createState) => { const subscribe: StoreApi['subscribe'] = ( selectorOrListener: | ((state: TState, prevState: TState) => void) - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any | ((state: TState) => any), - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any listener?: (selectedState: any, prevSelectedState: any) => void, ) => { - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any let selector: ((state: TState) => any) | undefined; - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any let actualListener: (state: any, prevState: any) => void; if (listener) { // Selector subscription case - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any selector = selectorOrListener as (state: TState) => any; actualListener = listener; } else { @@ -144,7 +144,7 @@ const createStoreImpl: CreateStoreImpl = (createState) => { const api = { setState, getState, getInitialState, subscribe }; const initialState = (state = createState(setState, getState, api)); - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any return api as any; }; diff --git a/packages/scan/src/web/utils/geiger.ts b/packages/scan/src/web/utils/geiger.ts index d6835ebe..2942518d 100644 --- a/packages/scan/src/web/utils/geiger.ts +++ b/packages/scan/src/web/utils/geiger.ts @@ -1,8 +1,6 @@ // MIT License // Copyright (c) 2025 Kristian Dupont -import { isFirefox, readLocalStorage } from './helpers'; - // 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 @@ -22,87 +20,3 @@ import { isFirefox, readLocalStorage } from './helpers'; // SOFTWARE. // Taken from: https://github.com/kristiandupont/react-geiger/blob/main/src/Geiger.tsx - -// Simple throttle for high-frequency calls -let lastPlayTime = 0; -const MIN_INTERVAL = 32; // ~30fps throttle - -// Pre-calculate common values -const BASE_VOLUME = 0.5; -const FREQ_MULTIPLIER = 200; -const DEFAULT_VOLUME = 0.5; - -// Ensure volume is between 0 and 1 -const storedVolume = Math.max( - 0, - Math.min(1, readLocalStorage('react-scan-volume') ?? DEFAULT_VOLUME), -); - -// Audio configurations for different browsers -const config = { - firefox: { - duration: 0.02, - oscillatorType: 'sine' as const, - startFreq: 220, - endFreq: 110, - attack: 0.0005, - volumeMultiplier: storedVolume, - }, - default: { - duration: 0.001, - oscillatorType: 'sine' as const, - startFreq: 440, - endFreq: 220, - attack: 0.0005, - volumeMultiplier: storedVolume, - }, -} as const; // Make entire config readonly - -// Cache the selected config -const audioConfig = isFirefox ? config.firefox : config.default; - -/** - * Plays a Geiger counter-like click sound - * Cross-browser compatible version (Firefox, Chrome, Safari) - */ -export const playGeigerClickSound = ( - audioContext: AudioContext, - amplitude: number, -) => { - const now = performance.now(); - if (now - lastPlayTime < MIN_INTERVAL) { - return; - } - lastPlayTime = now; - - // Cache currentTime for consistent timing - const currentTime = audioContext.currentTime; - const { duration, oscillatorType, startFreq, endFreq, attack } = audioConfig; - - // Pre-calculate volume once - const volume = - Math.max(BASE_VOLUME, amplitude) * audioConfig.volumeMultiplier; - - // Create and configure nodes in one go - const oscillator = new OscillatorNode(audioContext, { - type: oscillatorType, - frequency: startFreq + amplitude * FREQ_MULTIPLIER, - }); - - const gainNode = new GainNode(audioContext, { - gain: 0, - }); - - // Schedule all parameters - oscillator.frequency.exponentialRampToValueAtTime( - endFreq, - currentTime + duration, - ); - gainNode.gain.linearRampToValueAtTime(volume, currentTime + attack); - - // Connect and schedule playback - oscillator.connect(gainNode).connect(audioContext.destination); - - oscillator.start(currentTime); - oscillator.stop(currentTime + duration); -}; diff --git a/packages/scan/src/web/utils/helpers.ts b/packages/scan/src/web/utils/helpers.ts index ba30f82d..d0624c3d 100644 --- a/packages/scan/src/web/utils/helpers.ts +++ b/packages/scan/src/web/utils/helpers.ts @@ -79,15 +79,6 @@ export const removeLocalStorage = (storageKey: string): void => { } catch {} }; -export const toggleMultipleClasses = ( - element: HTMLElement, - classes: Array, -) => { - for (const cls of classes) { - element.classList.toggle(cls); - } -}; - interface WrapperBadge { type: 'memo' | 'forwardRef' | 'lazy' | 'suspense' | 'profiler' | 'strict'; title: string; diff --git a/packages/scan/src/web/utils/lerp.ts b/packages/scan/src/web/utils/lerp.ts deleted file mode 100644 index be31a954..00000000 --- a/packages/scan/src/web/utils/lerp.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const lerp = (start: number, end: number, t: number) => { - return start + (end - start) * t; -}; diff --git a/packages/scan/src/web/utils/log.ts b/packages/scan/src/web/utils/log.ts index d319009c..d7a4e4bb 100644 --- a/packages/scan/src/web/utils/log.ts +++ b/packages/scan/src/web/utils/log.ts @@ -68,16 +68,16 @@ export const log = (renders: Array) => { logMap.set(labelText, changeLog); } for (const [name, changeLog] of Array.from(logMap.entries())) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.group( `%c${name}`, 'background: hsla(0,0%,70%,.3); border-radius:3px; padding: 0 2px;', ); for (const { type, prev, next, unstable } of changeLog) { - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.log(`${type}:`, unstable ? '⚠️' : '', prev, '!==', next); } - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.groupEnd(); } }; @@ -87,14 +87,10 @@ export const logIntro = () => { window.hideIntro = undefined; return; } - // biome-ignore lint/suspicious/noConsole: Intended debug output + // oxlint-disable-next-line no-console console.log( '%c[·] %cReact Scan', 'font-weight:bold;color:#7a68e8;font-size:20px;', 'font-weight:bold;font-size:14px;', ); - // biome-ignore lint/suspicious/noConsole: Intended debug output - console.log( - 'Try React Scan Monitoring to target performance issues in production: https://react-scan.com/monitoring', - ); }; diff --git a/packages/scan/src/web/utils/lru.ts b/packages/scan/src/web/utils/lru.ts deleted file mode 100644 index a7d4ee50..00000000 --- a/packages/scan/src/web/utils/lru.ts +++ /dev/null @@ -1,121 +0,0 @@ -class LRUNode { - public next: LRUNode | undefined; - public prev: LRUNode | undefined; - - constructor( - public key: Key, - public value: Value, - ) {} -} - -/** - * Doubly linked list LRU - */ -export class LRUMap { - private nodes = new Map>(); - - private head: LRUNode | undefined; - private tail: LRUNode | undefined; - - constructor(public limit: number) {} - - has(key: Key) { - return this.nodes.has(key); - } - - get(key: Key): Value | undefined { - const result = this.nodes.get(key); - if (result) { - this.bubble(result); - return result.value; - } - return undefined; - } - - set(key: Key, value: Value): void { - // If node already exists, bubble up - if (this.nodes.has(key)) { - const result = this.nodes.get(key); - if (result) { - this.bubble(result); - } - return; - } - - // create a new node - const node = new LRUNode(key, value); - - // Set node as head - this.insertHead(node); - - // if the map is already at it's limit, remove the old tail - if (this.nodes.size === this.limit && this.tail) { - this.delete(this.tail.key); - } - - this.nodes.set(key, node); - } - - delete(key: Key): void { - const result = this.nodes.get(key); - - if (result) { - this.removeNode(result); - this.nodes.delete(key); - } - } - - private insertHead(node: LRUNode): void { - if (this.head) { - node.next = this.head; - this.head.prev = node; - } else { - this.tail = node; - node.next = undefined; - } - node.prev = undefined; - this.head = node; - } - - private removeNode(node: LRUNode): void { - // Link previous node to next node - if (node.prev) { - node.prev.next = node.next; - } - // and vice versa - if (node.next) { - node.next.prev = node.prev; - } - - if (node === this.tail) { - this.tail = node.prev; - if (this.tail) { - this.tail.next = undefined; - } - } - } - - private insertBefore( - node: LRUNode, - newNode: LRUNode, - ) { - newNode.next = node; - if (node.prev) { - newNode.prev = node.prev; - node.prev.next = newNode; - } else { - newNode.prev = undefined; - this.head = newNode; - } - node.prev = newNode; - } - - private bubble(node: LRUNode) { - if (node.prev) { - // Remove the node - this.removeNode(node); - // swap places with previous node - this.insertBefore(node.prev, node); - } - } -} diff --git a/packages/scan/src/web/utils/outline.ts b/packages/scan/src/web/utils/outline.ts deleted file mode 100644 index 65e432f7..00000000 --- a/packages/scan/src/web/utils/outline.ts +++ /dev/null @@ -1,97 +0,0 @@ -// THIS FILE WILL BE DELETED - -import type { Fiber } from 'bippy'; -import { type OutlineKey } from '~core/index'; -import type { AggregatedChange } from '~core/instrumentation'; - -export interface OutlineLabel { - alpha: number; - color: { r: number; g: number; b: number }; - reasons: number; // based on Reason enum - labelText: string; - textWidth: number; - activeOutline: Outline; -} - -// using intersection observer lets us get the boundingClientRect asynchronously without forcing a reflow. -// The browser can internally optimize the bounding rect query, so this will be faster then meticulously -// Batching getBoundingClientRect at the right time in the browser rendering pipeline. -// batchGetBoundingRects function can return in sub <10ms under good conditions, but may take much longer under poor conditions. -// We interpolate the outline rects to avoid the appearance of jitter -// reference: https://w3c.github.io/IntersectionObserver/ -/** - * - * @deprecated use getBatchedRectMap - */ -export const batchGetBoundingRects = ( - elements: Array, -): Promise> => { - return new Promise((resolve) => { - const results = new Map(); - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const element = entry.target; - const bounds = entry.boundingClientRect; - results.set(element, bounds); - } - observer.disconnect(); - resolve(results); - }); - - for (const element of elements) { - observer.observe(element); - } - }); -}; - -type ComponentName = string; - -export interface Outline { - domNode: Element; - /** Aggregated render info */ // TODO: Flatten AggregatedRender into Outline to avoid re-creating objects - // this render is useless when in active outlines (confirm this rob) - aggregatedRender: AggregatedRender; // maybe we should set this to null when its useless - - /* Active Info- we re-use the Outline object to avoid over-allocing objects, which is why we have a singular aggregatedRender and collection of it (groupedAggregatedRender) */ - alpha: number | null; - totalFrames: number | null; - /* - - Invariant: This scales at a rate of O(unique components rendered at the same (x,y) coordinates) - - renders with the same x/y position but different fibers will be a different fiber -> aggregated render entry. - */ - groupedAggregatedRender: Map | null; - - /* Rects for interpolation */ - current: DOMRect | null; - target: DOMRect | null; - /* This value is computed before the full rendered text is shown, so its only considered an estimate */ - estimatedTextWidth: number | null; // todo: estimated is stupid just make it the actual -} - -export enum RenderPhase { - Mount = 0b001, - Update = 0b010, - Unmount = 0b100, -} - -export const RENDER_PHASE_STRING_TO_ENUM = { - mount: RenderPhase.Mount, - update: RenderPhase.Update, - unmount: RenderPhase.Unmount, -} as const; - -export interface AggregatedRender { - name: ComponentName; - frame: number | null; - phase: number; // union of RenderPhase - time: number | null; - aggregatedCount: number; - forget: boolean; - changes: AggregatedChange; - unnecessary: boolean | null; - didCommit: boolean; - fps: number; - - computedKey: OutlineKey | null; - computedCurrent: DOMRect | null; // reference to dom rect to copy over to new outline made at new position -} diff --git a/packages/scan/src/web/utils/pin.ts b/packages/scan/src/web/utils/pin.ts index 46f0d279..8e10d77f 100644 --- a/packages/scan/src/web/utils/pin.ts +++ b/packages/scan/src/web/utils/pin.ts @@ -1,18 +1,4 @@ import type { Fiber } from 'bippy'; -import { Store } from '~core/index'; -import { findComponentDOMNode } from '~web/views/inspector/utils'; -import { readLocalStorage } from './helpers'; - -export interface FiberMetadata { - componentName: string; - parent: string; - position: number; - sibling: string | null; - path: string; - propKeys: string[]; -} - -const metadata = readLocalStorage('react-scann-pinned'); export const getFiberPath = (fiber: Fiber): string => { const pathSegments: string[] = []; @@ -36,96 +22,3 @@ export const getFiberPath = (fiber: Fiber): string => { return pathSegments.join('::'); }; - -export const getFiberMetadata = (fiber: Fiber): FiberMetadata | null => { - if (!fiber || !fiber.elementType) return null; - - const componentName = fiber.elementType.name || 'UnknownComponent'; - const position = fiber.index !== undefined ? fiber.index : -1; - const sibling = fiber.sibling?.elementType?.name || null; - - let parentFiber = fiber.return; - let parent = 'Root'; - - while (parentFiber) { - const parentName = parentFiber.elementType?.name; - - if (typeof parentName === 'string' && parentName.trim().length > 0) { - parent = parentName; - break; - } - - parentFiber = parentFiber.return; - } - - const path = getFiberPath(fiber); - - const propKeys = fiber.pendingProps - ? Object.keys(fiber.pendingProps).filter((key) => key !== 'children') - : []; - - return { componentName, parent, position, sibling, path, propKeys }; -}; - -const checkFiberMatch = (fiber: Fiber | undefined): boolean => { - if (!fiber || !fiber.elementType || !metadata?.componentName) return false; - - if (fiber.elementType.name !== metadata.componentName) return false; - - let currentParentFiber = fiber.return; - let parent = ''; - - while (currentParentFiber) { - if (currentParentFiber.elementType?.name) { - parent = currentParentFiber.elementType.name; - break; - } - currentParentFiber = currentParentFiber.return; - } - - if (parent !== metadata.parent) return false; - if (fiber.index !== metadata.position) return false; - - const fiberPath = getFiberPath(fiber); - return fiberPath === metadata.path; -}; - -const fiberQueue: Fiber[] = []; -let isProcessing = false; - -const processFiberQueue = (): void => { - if (isProcessing || fiberQueue.length === 0) return; - isProcessing = true; - - requestIdleCallback(() => { - while (fiberQueue.length > 0) { - const fiber = fiberQueue.shift(); - if (fiber && checkFiberMatch(fiber)) { - // biome-ignore lint/suspicious/noConsole: Intended debug output - console.log('🎯 Pinned component found!', fiber); - isProcessing = false; - - const componentElement = findComponentDOMNode(fiber); - - if (!componentElement) return; - - Store.inspectState.value = { - kind: 'focused', - focusedDomElement: componentElement, - fiber, - }; - return; - } - } - isProcessing = false; - }); -}; - -export const enqueueFiber = (fiber: Fiber) => { - if (metadata === null || metadata.componentName !== fiber.elementType?.name) { - return; - } - - fiberQueue.push(fiber); - if (!isProcessing) processFiberQueue(); -}; diff --git a/packages/scan/src/web/utils/preact/use-constant.ts b/packages/scan/src/web/utils/preact/use-constant.ts deleted file mode 100644 index 8c2b84e4..00000000 --- a/packages/scan/src/web/utils/preact/use-constant.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useDebugValue } from 'preact/hooks'; -import { useLazyRef } from './use-lazy-ref'; - -export function useConstant(supplier: () => T): T { - const value = useLazyRef(supplier).current; - useDebugValue(value); - return value; -} diff --git a/packages/scan/src/web/utils/preact/use-lazy-ref.ts b/packages/scan/src/web/utils/preact/use-lazy-ref.ts deleted file mode 100644 index a8745c5c..00000000 --- a/packages/scan/src/web/utils/preact/use-lazy-ref.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useDebugValue, useRef, type MutableRef } from 'preact/hooks'; - -export function useLazyRef(supplier: () => T): MutableRef { - const ref = useRef | null>(); - - if (!ref.current) { - ref.current = { - current: supplier(), - }; - } - - useDebugValue(ref.current); - - return ref.current; -} diff --git a/packages/scan/src/web/views/inspector/components-tree/index.tsx b/packages/scan/src/web/views/inspector/components-tree/index.tsx index f7bbd092..0995a8a2 100644 --- a/packages/scan/src/web/views/inspector/components-tree/index.tsx +++ b/packages/scan/src/web/views/inspector/components-tree/index.tsx @@ -762,7 +762,7 @@ export const ComponentsTree = () => { refIsHovering.current = false; }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { let isInitialTreeBuild = true; const buildTreeFromElements = (elements: Array) => { @@ -952,7 +952,7 @@ export const ComponentsTree = () => { return searchState.subscribe(setSearchValue); }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const unsubscribe = signalWidget.subscribe((state) => { refMainContainer.current?.style.setProperty('transition', 'width 0.1s'); diff --git a/packages/scan/src/web/views/inspector/overlay/index.tsx b/packages/scan/src/web/views/inspector/overlay/index.tsx index b957ddfa..55191e24 100644 --- a/packages/scan/src/web/views/inspector/overlay/index.tsx +++ b/packages/scan/src/web/views/inspector/overlay/index.tsx @@ -5,7 +5,7 @@ import { ReactScanInternals, Store } from '~core/index'; import { signalIsSettingsOpen, signalWidgetViews } from '~web/state'; import { IS_CLIENT } from '~web/utils/constants'; import { cn, throttle } from '~web/utils/helpers'; -import { lerp } from '~web/utils/lerp'; +const lerp = (start: number, end: number, t: number) => start + (end - start) * t; import { type States, findComponentDOMNode, @@ -660,7 +660,7 @@ export const ScanOverlay = () => { } }; - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const canvas = refCanvas.current; if (!canvas) return; diff --git a/packages/scan/src/web/views/inspector/utils.ts b/packages/scan/src/web/views/inspector/utils.ts index 07cf5904..6db17bfe 100644 --- a/packages/scan/src/web/views/inspector/utils.ts +++ b/packages/scan/src/web/views/inspector/utils.ts @@ -11,7 +11,6 @@ import { import { type PropsChange, ReactScanInternals } from '~core/index'; import { ChangeReason } from '~core/instrumentation'; import { isEqual } from '~core/utils'; -import { batchGetBoundingRects } from '~web/utils/outline'; import { globalInspectorState } from '.'; import type { ExtendedReactRenderer } from '../../../types'; import { TIMELINE_MAX_UPDATES } from './states'; @@ -198,8 +197,13 @@ export const getAssociatedFiberRect = async (element: Element) => { const stateNode = getFirstStateNode(associatedFiber); if (!stateNode) return null; - const rect = (await batchGetBoundingRects([stateNode])).get(stateNode); - if (!rect) return null; + const rect = await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + observer.disconnect(); + resolve(entries[0]?.boundingClientRect ?? null); + }); + observer.observe(stateNode); + }); return rect; }; @@ -1513,13 +1517,11 @@ export const safeGetValue = ( if (typeof value === 'function') return { value }; if (typeof value !== 'object') return { value }; - if (value instanceof Promise) { - // Handle promises without accessing them + if (isPromise(value)) { return { value: 'Promise' }; } try { - // Handle potential proxy traps or getter errors const proto = Object.getPrototypeOf(value); if (proto === Promise.prototype || proto?.constructor?.name === 'Promise') { return { value: 'Promise' }; diff --git a/packages/scan/src/web/views/inspector/what-changed.tsx b/packages/scan/src/web/views/inspector/what-changed.tsx index 89d95b76..27d19411 100644 --- a/packages/scan/src/web/views/inspector/what-changed.tsx +++ b/packages/scan/src/web/views/inspector/what-changed.tsx @@ -16,7 +16,7 @@ import { formatFunctionPreview, formatPath, getObjectDiff, - isPromise, + safeGetValue, } from './utils'; import { calculateTotalChanges, @@ -27,27 +27,6 @@ import { Store } from '~core/index'; export type Setter = Dispatch>; -const safeGetValue = (value: unknown): { value: unknown; error?: string } => { - if (value === null || value === undefined) return { value }; - if (typeof value === 'function') return { value }; - if (typeof value !== 'object') return { value }; - - if (isPromise(value)) { - return { value: 'Promise' }; - } - - try { - const proto = Object.getPrototypeOf(value); - if (proto === Promise.prototype || proto?.constructor?.name === 'Promise') { - return { value: 'Promise' }; - } - - return { value }; - } catch { - return { value: null, error: 'Error accessing value' }; - } -}; - export const WhatChanged = /* @__PURE__ */ memo(() => { const [isExpanded, setIsExpanded] = useState(true); const aggregatedChanges = useInspectedFiberChangeStore(); @@ -71,7 +50,7 @@ export const WhatChanged = /* @__PURE__ */ memo(() => { .filter(([, value]) => value.kind === 'initialized') .map(([key, value]) => [ key, - // biome-ignore lint/style/noNonNullAssertion: + // oxlint-disable-next-line typescript/no-non-null-assertion value.kind === 'partially-initialized' ? null! : value.changes, ]), ); @@ -283,7 +262,7 @@ const WhatsChangedHeader = memo(() => { interface SectionProps { title: string; isExpanded: boolean; - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any changes: Map; renderName?: (name: string) => ReactNode; } diff --git a/packages/scan/src/web/views/inspector/whats-changed/use-change-store.ts b/packages/scan/src/web/views/inspector/whats-changed/use-change-store.ts index 7bfab8b9..9b2c2d65 100644 --- a/packages/scan/src/web/views/inspector/whats-changed/use-change-store.ts +++ b/packages/scan/src/web/views/inspector/whats-changed/use-change-store.ts @@ -42,12 +42,12 @@ export type AggregatedChanges = { }; export type AllAggregatedChanges = { - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any propsChanges: Map; - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any stateChanges: Map; contextChanges: Map< - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any any, | { changes: AggregatedChanges; kind: 'initialized' } | { @@ -79,7 +79,7 @@ const getContextChangesValue = ( }; const processChanges = ( changes: Array<{ name: string; value: unknown; prevValue?: unknown }>, - // biome-ignore lint/suspicious/noExplicitAny: + // oxlint-disable-next-line typescript/no-explicit-any targetMap: Map, ) => { for (const change of changes) { @@ -374,7 +374,7 @@ export const useInspectedFiberChangeStore = (opts?: { : null; const fiberId = fiber ? getFiberId(fiber) : null; - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const interval = setInterval(() => { // optimization to avoid unconditional renders @@ -433,7 +433,7 @@ export const useInspectedFiberChangeStore = (opts?: { }, [fiberId]); // cleanup - // biome-ignore lint/correctness/useExhaustiveDependencies: component should really remount when fiber changes, but instead we just re-run effects (should fix) + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { return () => { setAggregatedChanges({ diff --git a/packages/scan/src/web/views/notifications/collapsed-event.tsx b/packages/scan/src/web/views/notifications/collapsed-event.tsx index d9e68538..ca62049d 100644 --- a/packages/scan/src/web/views/notifications/collapsed-event.tsx +++ b/packages/scan/src/web/views/notifications/collapsed-event.tsx @@ -31,7 +31,7 @@ const useNestedFlash = ({ const flashedFor = useRef(0); const lastFlashTime = useRef(0); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (flashedFor.current >= totalEvents) { return; diff --git a/packages/scan/src/web/views/notifications/data.ts b/packages/scan/src/web/views/notifications/data.ts index a9384746..40454c0a 100644 --- a/packages/scan/src/web/views/notifications/data.ts +++ b/packages/scan/src/web/views/notifications/data.ts @@ -29,7 +29,7 @@ export const getComponentName = (path: Array) => { if (filteredPath.length === 0) { return path.at(-1) ?? 'Unknown'; } - // biome-ignore lint/style/noNonNullAssertion: invariant + // oxlint-disable-next-line typescript/no-non-null-assertion return filteredPath.at(-1)!; }; @@ -219,5 +219,5 @@ export const NotificationStateContext = createContext<{ route: NotificationsState['route']; routeMessage: NotificationsState['routeMessage'] | null; }) => void; - // biome-ignore lint/style/noNonNullAssertion: we do not use default context values + // oxlint-disable-next-line typescript/no-non-null-assertion }>(null!); diff --git a/packages/scan/src/web/views/notifications/notification-header.tsx b/packages/scan/src/web/views/notifications/notification-header.tsx index 9ca0014a..b90c0fcd 100644 --- a/packages/scan/src/web/views/notifications/notification-header.tsx +++ b/packages/scan/src/web/views/notifications/notification-header.tsx @@ -1,4 +1,3 @@ -// import { signalNotificationsOpen, signalSettingsOpen } from '~web/state'; import { cn } from '~web/utils/helpers'; import { NotificationEvent, diff --git a/packages/scan/src/web/views/notifications/notifications.tsx b/packages/scan/src/web/views/notifications/notifications.tsx index fe576dca..405a3fa0 100644 --- a/packages/scan/src/web/views/notifications/notifications.tsx +++ b/packages/scan/src/web/views/notifications/notifications.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { not_globally_unique_generateId } from '~core/monitor/utils'; +import { not_globally_unique_generateId } from '~core/utils'; import { useToolbarEventLog } from '~core/notifications/event-tracking'; import { FiberRenders } from '~core/notifications/performance'; import { iife, invariantError } from '~core/notifications/performance-utils'; @@ -192,7 +192,7 @@ export const NotificationAudio = () => { (event) => getEventSeverity(event) === 'high', ).length; - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { // todo: sync with options const audioEnabledString = localStorage.getItem( @@ -223,7 +223,7 @@ export const NotificationAudio = () => { } }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const { audioNotificationsOptions } = notificationState; if (!audioNotificationsOptions.enabled) { diff --git a/packages/scan/src/web/views/notifications/other-visualization.tsx b/packages/scan/src/web/views/notifications/other-visualization.tsx index 3de5ae66..b20dc751 100644 --- a/packages/scan/src/web/views/notifications/other-visualization.tsx +++ b/packages/scan/src/web/views/notifications/other-visualization.tsx @@ -113,7 +113,7 @@ export const OtherVisualization = ({ const root = useContext(ToolbarElementContext); // for when a user clicks a bar of a non render, and gets sent to the other visualization and passes a route message on the way - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (notificationState.routeMessage?.name) { const container = root?.querySelector('#overview-scroll-container'); @@ -130,7 +130,7 @@ export const OtherVisualization = ({ } }, [notificationState.route]); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (notificationState.route === 'other-visualization') { setExpandedItems((prev) => diff --git a/packages/scan/src/web/views/notifications/popover.tsx b/packages/scan/src/web/views/notifications/popover.tsx index 34aaa012..354cef17 100644 --- a/packages/scan/src/web/views/notifications/popover.tsx +++ b/packages/scan/src/web/views/notifications/popover.tsx @@ -67,7 +67,7 @@ export const Popover = ({ } }; - // biome-ignore lint/correctness/useExhaustiveDependencies: its not pure but fine + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { updateRect(); }, [triggerRef.current]); @@ -135,6 +135,8 @@ export const Popover = ({ }; }; + const popoverPosition = getPopoverPosition(); + return ( <> {portalEl && @@ -145,7 +147,7 @@ export const Popover = ({ ref={popoverRef} className={cn([ 'absolute z-100 bg-white text-black rounded-lg px-3 py-2 shadow-lg', - 'transform transition-all duration-120 ease-[cubic-bezier(0.23,1,0.32,1)]', + 'transition-[opacity] duration-120 ease-out', 'after:content-[""] after:absolute after:top-[100%]', 'after:left-1/2 after:-translate-x-1/2', 'after:w-[10px] after:h-[6px]', @@ -154,14 +156,15 @@ export const Popover = ({ 'after:border-t-[6px] after:border-t-white', 'pointer-events-none', popoverState === 'opening' || popoverState === 'closing' - ? 'opacity-0 translate-y-1' - : 'opacity-100 translate-y-0', + ? 'opacity-0' + : 'opacity-100', ])} style={{ - top: getPopoverPosition().top + 'px', - left: getPopoverPosition().left + 'px', - transform: 'translate(-50%, -100%)', + top: popoverPosition.top + 'px', + left: popoverPosition.left + 'px', + transform: `translate(-50%, calc(-100% - 4px)) scale(${popoverState === 'open' ? 1 : 0.97})`, minWidth: '175px', + willChange: 'opacity, transform', }} > {children} diff --git a/packages/scan/src/web/views/notifications/slowdown-history.tsx b/packages/scan/src/web/views/notifications/slowdown-history.tsx index e4b68b93..d86f5968 100644 --- a/packages/scan/src/web/views/notifications/slowdown-history.tsx +++ b/packages/scan/src/web/views/notifications/slowdown-history.tsx @@ -335,7 +335,7 @@ export const useLaggedEvents = (lagMs = 150) => { const { notificationState } = useNotificationsContext(); const [laggedEvents, setLaggedEvents] = useState(notificationState.events); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { setTimeout(() => { setLaggedEvents(notificationState.events); diff --git a/packages/scan/src/web/views/toolbar/index.tsx b/packages/scan/src/web/views/toolbar/index.tsx index 50caa8a9..27acd372 100644 --- a/packages/scan/src/web/views/toolbar/index.tsx +++ b/packages/scan/src/web/views/toolbar/index.tsx @@ -123,7 +123,7 @@ export const Toolbar = constant(() => { inspectColor = '#999'; } - // biome-ignore lint/correctness/useExhaustiveDependencies: + // oxlint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (signalWidgetViews.value.view !== 'notifications') { return; diff --git a/packages/scan/src/web/widget/header.tsx b/packages/scan/src/web/widget/header.tsx index 58080a41..c1223743 100644 --- a/packages/scan/src/web/widget/header.tsx +++ b/packages/scan/src/web/widget/header.tsx @@ -1,99 +1,9 @@ -import { useEffect, useState } from 'preact/hooks'; import { Store } from '~core/index'; import { Icon } from '~web/components/icon'; import { useDelayedValue } from '~web/hooks/use-delayed-value'; import { signalWidgetViews } from '~web/state'; import { cn } from '~web/utils/helpers'; import { HeaderInspect } from '~web/views/inspector/header'; -import { getOverrideMethods } from '~web/views/inspector/utils'; - -// const REPLAY_DELAY_MS = 300; - -export const BtnReplay = () => { - // const refTimeout = useRef(); - // const replayState = useRef({ - // isReplaying: false, - // toggleDisabled: (disabled: boolean, button: HTMLElement) => { - // button.classList[disabled ? 'add' : 'remove']('disabled'); - // }, - // }); - - const [canEdit, setCanEdit] = useState(false); - - useEffect(() => { - const { overrideProps } = getOverrideMethods(); - const canEdit = !!overrideProps; - - requestAnimationFrame(() => { - setCanEdit(canEdit); - }); - }, []); - - // const handleReplay = (e: MouseEvent) => { - // e.stopPropagation(); - // const { overrideProps, overrideHookState } = getOverrideMethods(); - // const state = replayState.current; - // const button = e.currentTarget as HTMLElement; - - // const inspectState = Store.inspectState.value; - // if (state.isReplaying || inspectState.kind !== 'focused') return; - - // const { parentCompositeFiber } = getCompositeComponentFromElement( - // inspectState.focusedDomElement, - // ); - // if (!parentCompositeFiber || !overrideProps || !overrideHookState) return; - - // state.isReplaying = true; - // state.toggleDisabled(true, button); - - // void replayComponent(parentCompositeFiber) - // .catch(() => void 0) - // .finally(() => { - // clearTimeout(refTimeout.current); - // if (document.hidden) { - // state.isReplaying = false; - // state.toggleDisabled(false, button); - // } else { - // refTimeout.current = setTimeout(() => { - // state.isReplaying = false; - // state.toggleDisabled(false, button); - // }, REPLAY_DELAY_MS); - // } - // }); - // }; - - if (!canEdit) return null; - - return ( - - ); -}; - -// const useSubscribeFocusedFiber = (onUpdate: () => void) => { -// // biome-ignore lint/correctness/useExhaustiveDependencies: no deps -// useEffect(() => { -// const subscribe = () => { -// if (Store.inspectState.value.kind !== 'focused') { -// return; -// } -// onUpdate(); -// }; - -// const unSubReportTime = Store.lastReportTime.subscribe(subscribe); -// const unSubState = Store.inspectState.subscribe(subscribe); -// return () => { -// unSubReportTime(); -// unSubState(); -// }; -// }, []); -// }; export const Header = () => { const isInitialView = useDelayedValue( @@ -130,7 +40,6 @@ export const Header = () => { - {/* {Store.inspectState.value.kind !== 'inspect-off' && } */}