diff --git a/packages/scan-react/PORT_SPEC.md b/packages/scan-react/PORT_SPEC.md new file mode 100644 index 00000000..c013c04e --- /dev/null +++ b/packages/scan-react/PORT_SPEC.md @@ -0,0 +1,131 @@ +# react-scan Preact → idiomatic React port spec + +This package (`packages/scan-react`) is a **parallel** React port of the +Preact toolbar UI that lives in `packages/scan/src/web`. The original files +were copied verbatim into `packages/scan-react/src/web`; your job is to rewrite +the Preact-specific bits of a single file into idiomatic React **in place** in +this package. Do not touch `packages/scan` (the Preact original) — it stays. + +The shared profiling engine under `~core/*` is re-used as-is from the original +package (the tsconfig alias points `~core/*` at `../scan/src/core/*`). Do NOT +copy or port core; only adapt how the UI consumes it (see "Core hooks" below). + +## Module / import mapping + +| Preact | React equivalent | +|------------------------------------------|------------------| +| `from "preact/hooks"` (useState/useEffect/useRef/useMemo/useCallback/useLayoutEffect/useContext) | `from "react"` | +| `from "preact"` `createElement`, `Component`, `createContext`, `Fragment`, `cloneElement`, `isValidElement`, `toChildArray` | `from "react"` (`toChildArray` → `React.Children.toArray`) | +| `from "preact"` types `ComponentType`, `FunctionComponent`, `ComponentChildren`, `VNode`, `Attributes`, `RefObject` | `from "react"` (`ComponentChildren` → `ReactNode`, `VNode` → `ReactElement`) | +| `import type { JSX } from "preact"` | `import type { JSX } from "react"` (or use `React.JSX`) | +| `from "preact/compat"` `memo`, `forwardRef`, `ForwardedRef`, `ReactNode`, `SetStateAction`, `Dispatch`, `useSyncExternalStore`, `createPortal` | `from "react"` (and `createPortal` from `"react-dom"`) | +| `render(vnode, container)` (`from "preact"`) | `createRoot(container).render(vnode)` (`from "react-dom/client"`); keep the root to call `root.unmount()` later | +| `@preact/signals` (`signal`, `computed`, `effect`, `untracked`, `useSignal`, `useSignalEffect`, `useComputed`, `type Signal`, `type ReadonlySignal`) | `~web/utils/signals` — a compat module already written for you (see below) | + +Use the `~web/*` and `~core/*` path aliases that already exist in this +package's tsconfig. Keep relative imports between web files working. + +## Signals → React (the important part) + +`~web/utils/signals` provides API-compatible `signal`, `computed`, `effect`, +`untracked` plus the React hooks `useSignalValue`, `useComputed`, `useSignal`, +`useSignalEffect`. Read its doc comment. + +Rules: + +1. **Module-level `signal(...)` / `computed(...)` definitions** (e.g. in + `state.ts`, `views/index.tsx`, `inspector/states.ts`): keep them as + `signal()` / `computed()` imported from `~web/utils/signals`. Their + `.value` / `.peek()` / `.subscribe()` API is preserved, so non-component + consumers (event handlers, `effect`s, `~core` code) keep working unchanged. + +2. **Reading a signal inside a component's render body**: the Preact code read + `someSignal.value` directly and relied on auto-subscription. That does NOT + work in React. Replace each such read with a hook call at the top of the + component: + ```tsx + // before (Preact): const widget = signalWidget.value; + const widget = useSignalValue(signalWidget); + // before (Preact, derived): className={someComputed.value} + const className = useSignalValue(someComputed); + ``` + Reads of `.value` that are NOT in render (inside `onClick`, `useEffect`, + helpers, module scope) stay as plain `.value`. + +3. **`useSignal(x)`**: prefer converting trivially-local signals to `useState` + when the `.value` call sites are few and local (more idiomatic). If `.value` + is threaded through many helpers, keep `useSignal` from the compat module. + State which you did in your summary. + +4. **`useSignalEffect(fn)`**: use `useSignalEffect` from the compat module + (it re-subscribes to whatever signals `fn` reads). If the effect's + dependencies are obvious and stable, an idiomatic `useEffect` with explicit + `useSignalValue` reads + a dep array is preferred — use judgment. + +5. **`computed(...)` read in a component**: read via `useSignalValue(theComputed)`. + +## Core hooks + +`~core/notifications/event-tracking` exports `useToolbarEventLog`, which in the +original imports `useSyncExternalStore` from `preact/compat`. When a file in +this package consumes that hook it must use React's hook dispatcher. If you hit +this, do NOT edit core; instead read `toolbarEventStore` from +`~core/notifications/event-tracking` and subscribe to it with React's +`useSyncExternalStore` locally (or via a small `~web` wrapper hook). Note this +in your summary so it can be centralized. + +## JSX idiom differences (apply everywhere) + +- `class=` → `className=` (Preact allows both; React requires `className`). +- `for=` → `htmlFor=`. +- `onInput` on form controls → `onChange` (React's `onChange` fires on input). + Preserve the handler body; the event type becomes + `React.ChangeEvent` etc. +- `onDblClick` → `onDoubleClick`. +- Event handler param types: `JSX.TargetedEvent<...>` → the matching React + synthetic event type (`React.MouseEvent`, `React.ChangeEvent`, + `React.KeyboardEvent`, `React.PointerEvent`, ...). +- `ref` callbacks returning a value: React 18 ref callbacks must return void or + a cleanup; keep them returning nothing unless on React 19. +- `style` accepts an object — unchanged. Numeric values unchanged. +- `dangerouslySetInnerHTML` — unchanged. +- Boolean/aria/data attributes — unchanged. +- SVG: `class` → `className`; most attrs unchanged in React 19. Keep + `xmlns`, `viewBox`, etc. +- Inline `key` usage — unchanged. + +## Class components & error boundaries + +`toolbar.tsx` has a `Component`-based error boundary and `inspector/index.tsx` +has a class component. Port these to `React.Component` with the same lifecycle +methods (`getDerivedStateFromError`, `componentDidCatch`, `render`, +`componentDidMount`, `componentWillUnmount`). `this.props.children` is typed via +`PropsWithChildren`. Keep behavior identical. + +## Mounting (`toolbar.tsx`) + +Replace Preact `render(vnode, container)` with React 18+ `createRoot`: +```ts +import { createRoot, type Root } from "react-dom/client"; +// mount +const root = createRoot(container); +root.render(...); +// unmount (replaces the double `render(null, container)` hack) +root.unmount(); +``` +Store the `Root` so the patched `container.remove` can call `root.unmount()` +once (the Preact "double render(null)" comment/hack is no longer needed). + +## General rules + +- **Preserve behavior, logic, comments, and public exports exactly.** This is a + port, not a refactor. Do not rename exports, change algorithms, or drop + comments. Only change framework API surface and JSX idioms. +- Match the surrounding code style (the repo uses double quotes, 2-space + indent, named exports). +- Keep `/* @__PURE__ */` annotations. +- Do not add new dependencies beyond `react` / `react-dom`. +- Leave non-Preact `.ts` utility files alone — they already work. +- If you discover a cross-file concern (e.g. a shared type that needs a React + equivalent, or a core hook issue), implement the local fix AND flag it in + your summary. diff --git a/packages/scan-react/global.d.ts b/packages/scan-react/global.d.ts new file mode 100644 index 00000000..2186250c --- /dev/null +++ b/packages/scan-react/global.d.ts @@ -0,0 +1,7 @@ +// Raw-string CSS imports (resolved as text by the bundler). Mirrors the +// declaration in the upstream `react-scan` package's global.d.ts so the +// re-used `~core` engine type-checks under this package too. +declare module "*.css" { + const content: string; + export default content; +} diff --git a/packages/scan-react/package.json b/packages/scan-react/package.json new file mode 100644 index 00000000..ff3d4635 --- /dev/null +++ b/packages/scan-react/package.json @@ -0,0 +1,33 @@ +{ + "name": "react-scan-react", + "version": "0.0.0", + "private": true, + "description": "Idiomatic-React port of the react-scan toolbar UI (parallel to the Preact UI in `react-scan`).", + "type": "module", + "license": "MIT", + "author": { + "name": "Aiden Bai", + "email": "aiden@million.dev", + "url": "https://million.dev" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "bippy": "0.5.39", + "react-grab": "latest", + "react-doctor": "latest" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.0", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-scan": "workspace:*", + "typescript": "*" + } +} diff --git a/packages/scan-react/src/web/assets/css/styles.css b/packages/scan-react/src/web/assets/css/styles.css new file mode 100644 index 00000000..c9789382 --- /dev/null +++ b/packages/scan-react/src/web/assets/css/styles.css @@ -0,0 +1,3052 @@ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-950: oklch(25.8% 0.092 26.042); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-zinc-200: oklch(92% 0.004 286.32); + --color-zinc-400: oklch(70.5% 0.015 286.067); + --color-zinc-500: oklch(55.2% 0.016 285.938); + --color-zinc-600: oklch(44.2% 0.017 285.786); + --color-zinc-700: oklch(37% 0.013 285.805); + --color-zinc-800: oklch(27.4% 0.006 286.033); + --color-zinc-900: oklch(21% 0.006 285.885); + --color-neutral-300: oklch(87% 0 0); + --color-neutral-400: oklch(70.8% 0 0); + --color-neutral-500: oklch(55.6% 0 0); + --color-neutral-700: oklch(37.1% 0 0); + --color-black: #000; + --color-white: #fff; + --spacing: 4px; + --container-md: 448px; + --text-xs: 12px; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 14px; + --text-sm--line-height: calc(1.25 / 0.875); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-wide: 0.025em; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --blur-sm: 8px; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; + font-feature-settings: normal; + font-variation-settings: normal; + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::-moz-placeholder { + opacity: 1; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::-moz-placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + -webkit-appearance: button; + -moz-appearance: button; + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .pointer-events-auto { + pointer-events: auto; + } + .pointer-events-bounding-box { + pointer-events: bounding-box; + } + .pointer-events-none { + pointer-events: none; + } + .collapse { + visibility: collapse; + } + .visible { + visibility: visible; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .inset-x-1 { + inset-inline: calc(var(--spacing) * 1); + } + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + .start { + inset-inline-start: var(--spacing); + } + .end { + inset-inline-end: var(--spacing); + } + .-top-1 { + top: calc(var(--spacing) * -1); + } + .-top-2\.5 { + top: calc(var(--spacing) * -2.5); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .top-0\.5 { + top: calc(var(--spacing) * 0.5); + } + .top-1\/2 { + top: calc(1 / 2 * 100%); + } + .top-2 { + top: calc(var(--spacing) * 2); + } + .-right-1 { + right: calc(var(--spacing) * -1); + } + .-right-2\.5 { + right: calc(var(--spacing) * -2.5); + } + .right-0 { + right: calc(var(--spacing) * 0); + } + .right-0\.5 { + right: calc(var(--spacing) * 0.5); + } + .right-2 { + right: calc(var(--spacing) * 2); + } + .right-4 { + right: calc(var(--spacing) * 4); + } + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } + .bottom-4 { + bottom: calc(var(--spacing) * 4); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .left-3 { + left: calc(var(--spacing) * 3); + } + .z-10 { + z-index: 10; + } + .z-50 { + z-index: 50; + } + .z-100 { + z-index: 100; + } + .z-\[214748365\] { + z-index: 214748365; + } + .z-\[214748367\] { + z-index: 214748367; + } + .z-\[124124124124\] { + z-index: 124124124124; + } + .container { + width: 100%; + @media (width >= 640px) { + max-width: 640px; + } + @media (width >= 768px) { + max-width: 768px; + } + @media (width >= 1024px) { + max-width: 1024px; + } + @media (width >= 1280px) { + max-width: 1280px; + } + @media (width >= 1536px) { + max-width: 1536px; + } + } + .m-\[2px\] { + margin: 2px; + } + .mx-0\.5 { + margin-inline: calc(var(--spacing) * 0.5); + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mr-0\.5 { + margin-right: calc(var(--spacing) * 0.5); + } + .mr-1 { + margin-right: calc(var(--spacing) * 1); + } + .mr-1\.5 { + margin-right: calc(var(--spacing) * 1.5); + } + .mr-16 { + margin-right: calc(var(--spacing) * 16); + } + .mr-auto { + margin-right: auto; + } + .mb-1\.5 { + margin-bottom: calc(var(--spacing) * 1.5); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-px { + margin-bottom: 1px; + } + .\!ml-0 { + margin-left: calc(var(--spacing) * 0) !important; + } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } + .ml-1\.5 { + margin-left: calc(var(--spacing) * 1.5); + } + .ml-auto { + margin-left: auto; + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .aspect-square { + aspect-ratio: 1 / 1; + } + .h-1 { + height: calc(var(--spacing) * 1); + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-4\/5 { + height: calc(4 / 5 * 100%); + } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-7 { + height: calc(var(--spacing) * 7); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-12 { + height: calc(var(--spacing) * 12); + } + .h-\[28px\] { + height: 28px; + } + .h-\[48px\] { + height: 48px; + } + .h-\[50px\] { + height: 50px; + } + .h-\[150px\] { + height: 150px; + } + .h-\[235px\] { + height: 235px; + } + .h-\[calc\(100\%-25px\)\] { + height: calc(100% - 25px); + } + .h-\[calc\(100\%-40px\)\] { + height: calc(100% - 40px); + } + .h-\[calc\(100\%-48px\)\] { + height: calc(100% - 48px); + } + .h-\[calc\(100\%-150px\)\] { + height: calc(100% - 150px); + } + .h-\[calc\(100\%-200px\)\] { + height: calc(100% - 200px); + } + .h-fit { + height: -moz-fit-content; + height: fit-content; + } + .h-full { + height: 100%; + } + .h-screen { + height: 100vh; + } + .max-h-0 { + max-height: calc(var(--spacing) * 0); + } + .max-h-9 { + max-height: calc(var(--spacing) * 9); + } + .max-h-40 { + max-height: calc(var(--spacing) * 40); + } + .min-h-9 { + min-height: calc(var(--spacing) * 9); + } + .min-h-\[48px\] { + min-height: 48px; + } + .min-h-fit { + min-height: -moz-fit-content; + min-height: fit-content; + } + .w-1 { + width: calc(var(--spacing) * 1); + } + .w-1\/2 { + width: calc(1 / 2 * 100%); + } + .w-1\/3 { + width: calc(1 / 3 * 100%); + } + .w-2\/4 { + width: calc(2 / 4 * 100%); + } + .w-3 { + width: calc(var(--spacing) * 3); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-4\/5 { + width: calc(4 / 5 * 100%); + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-80 { + width: calc(var(--spacing) * 80); + } + .w-\[20px\] { + width: 20px; + } + .w-\[72px\] { + width: 72px; + } + .w-\[90\%\] { + width: 90%; + } + .w-\[calc\(100\%-200px\)\] { + width: calc(100% - 200px); + } + .w-fit { + width: -moz-fit-content; + width: fit-content; + } + .w-full { + width: 100%; + } + .w-px { + width: 1px; + } + .w-screen { + width: 100vw; + } + .max-w-md { + max-width: var(--container-md); + } + .min-w-0 { + min-width: calc(var(--spacing) * 0); + } + .min-w-\[200px\] { + min-width: 200px; + } + .min-w-fit { + min-width: -moz-fit-content; + min-width: fit-content; + } + .flex-1 { + flex: 1; + } + .shrink-0 { + flex-shrink: 0; + } + .grow { + flex-grow: 1; + } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-\[200\%\] { + --tw-translate-y: calc(200% * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-y-0 { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .scale-110 { + --tw-scale-x: 110%; + --tw-scale-y: 110%; + --tw-scale-z: 110%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + .-rotate-90 { + rotate: calc(90deg * -1); + } + .rotate-90 { + rotate: 90deg; + } + .rotate-180 { + rotate: 180deg; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .animate-fade-in { + animation: fadeIn ease-in forwards; + } + .cursor-default { + cursor: default; + } + .cursor-e-resize { + cursor: e-resize; + } + .cursor-ew-resize { + cursor: ew-resize; + } + .cursor-ew-resize { + cursor: ew-resize; + } + .cursor-move { + cursor: move; + } + .cursor-move { + cursor: move; + } + .cursor-nesw-resize { + cursor: nesw-resize; + } + .cursor-nesw-resize { + cursor: nesw-resize; + } + .cursor-ns-resize { + cursor: ns-resize; + } + .cursor-ns-resize { + cursor: ns-resize; + } + .cursor-nwse-resize { + cursor: nwse-resize; + } + .cursor-nwse-resize { + cursor: nwse-resize; + } + .cursor-pointer { + cursor: pointer; + } + .cursor-w-resize { + cursor: w-resize; + } + .\[touch-action\:none\] { + touch-action: none; + } + .resize { + resize: both; + } + .flex-col { + flex-direction: column; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .items-start { + align-items: flex-start; + } + .items-stretch { + align-items: stretch; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .justify-start { + justify-content: flex-start; + } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .space-y-1\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); + } + } + .gap-x-0\.5 { + -moz-column-gap: calc(var(--spacing) * 0.5); + column-gap: calc(var(--spacing) * 0.5); + } + .gap-x-1 { + -moz-column-gap: calc(var(--spacing) * 1); + column-gap: calc(var(--spacing) * 1); + } + .gap-x-1\.5 { + -moz-column-gap: calc(var(--spacing) * 1.5); + column-gap: calc(var(--spacing) * 1.5); + } + .gap-x-2 { + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + } + .gap-x-3 { + -moz-column-gap: calc(var(--spacing) * 3); + column-gap: calc(var(--spacing) * 3); + } + .gap-x-4 { + -moz-column-gap: calc(var(--spacing) * 4); + column-gap: calc(var(--spacing) * 4); + } + .gap-y-0\.5 { + row-gap: calc(var(--spacing) * 0.5); + } + .gap-y-1 { + row-gap: calc(var(--spacing) * 1); + } + .gap-y-2 { + row-gap: calc(var(--spacing) * 2); + } + .gap-y-4 { + row-gap: calc(var(--spacing) * 4); + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-zinc-800 { + :where(& > :not(:last-child)) { + border-color: var(--color-zinc-800); + } + } + .place-self-center { + place-self: center; + } + .self-end { + align-self: flex-end; + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .\!overflow-visible { + overflow: visible !important; + } + .overflow-auto { + overflow: auto; + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .overflow-x-hidden { + overflow-x: hidden; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded { + border-radius: 4px; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-md { + border-radius: var(--radius-md); + } + .rounded-sm { + border-radius: var(--radius-sm); + } + .rounded-t-lg { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + } + .rounded-t-sm { + border-top-left-radius: var(--radius-sm); + border-top-right-radius: var(--radius-sm); + } + .rounded-l-md { + border-top-left-radius: var(--radius-md); + border-bottom-left-radius: var(--radius-md); + } + .rounded-l-sm { + border-top-left-radius: var(--radius-sm); + border-bottom-left-radius: var(--radius-sm); + } + .rounded-tl-lg { + border-top-left-radius: var(--radius-lg); + } + .rounded-r-md { + border-top-right-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + } + .rounded-r-sm { + border-top-right-radius: var(--radius-sm); + border-bottom-right-radius: var(--radius-sm); + } + .rounded-tr-lg { + border-top-right-radius: var(--radius-lg); + } + .rounded-br-lg { + border-bottom-right-radius: var(--radius-lg); + } + .rounded-bl-lg { + border-bottom-left-radius: var(--radius-lg); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-4 { + border-style: var(--tw-border-style); + border-width: 4px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-l { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .border-l-0 { + border-left-style: var(--tw-border-style); + border-left-width: 0px; + } + .border-l-1 { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .border-none { + --tw-border-style: none; + border-style: none; + } + .\!border-red-500 { + border-color: var(--color-red-500) !important; + } + .border-\[\#1e1e1e\] { + border-color: #1e1e1e; + } + .border-\[\#222\] { + border-color: #222; + } + .border-\[\#333\] { + border-color: #333; + } + .border-\[\#27272A\] { + border-color: #27272A; + } + .border-transparent { + border-color: transparent; + } + .border-zinc-800 { + border-color: var(--color-zinc-800); + } + .bg-\[\#0A0A0A\] { + background-color: #0A0A0A; + } + .bg-\[\#1D3A66\] { + background-color: #1D3A66; + } + .bg-\[\#1E1E1E\] { + background-color: #1E1E1E; + } + .bg-\[\#1a2a1a\] { + background-color: #1a2a1a; + } + .bg-\[\#1e1e1e\] { + background-color: #1e1e1e; + } + .bg-\[\#2a1515\] { + background-color: #2a1515; + } + .bg-\[\#4b4b4b\] { + background-color: #4b4b4b; + } + .bg-\[\#5f3f9a\] { + background-color: #5f3f9a; + } + .bg-\[\#5f3f9a\]\/40 { + background-color: color-mix(in oklab, #5f3f9a 40%, transparent); + } + .bg-\[\#6a369e\] { + background-color: #6a369e; + } + .bg-\[\#8e61e3\] { + background-color: #8e61e3; + } + .bg-\[\#7521c8\] { + background-color: #7521c8; + } + .bg-\[\#18181B\] { + background-color: #18181B; + } + .bg-\[\#18181B\]\/50 { + background-color: color-mix(in oklab, #18181B 50%, transparent); + } + .bg-\[\#27272A\] { + background-color: #27272A; + } + .bg-\[\#44444a\] { + background-color: #44444a; + } + .bg-\[\#141414\] { + background-color: #141414; + } + .bg-\[\#214379d4\] { + background-color: #214379d4; + } + .bg-\[\#412162\] { + background-color: #412162; + } + .bg-\[\#EFD81A\] { + background-color: #EFD81A; + } + .bg-\[\#b77116\] { + background-color: #b77116; + } + .bg-\[\#b94040\] { + background-color: #b94040; + } + .bg-\[\#d36cff\] { + background-color: #d36cff; + } + .bg-\[\#efd81a6b\] { + background-color: #efd81a6b; + } + .bg-black { + background-color: var(--color-black); + } + .bg-black\/40 { + background-color: color-mix(in srgb, #000 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 40%, transparent); + } + } + .bg-green-500\/50 { + background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-green-500) 50%, transparent); + } + } + .bg-green-500\/60 { + background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 60%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-green-500) 60%, transparent); + } + } + .bg-neutral-700 { + background-color: var(--color-neutral-700); + } + .bg-purple-500 { + background-color: var(--color-purple-500); + } + .bg-purple-500\/90 { + background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-purple-500) 90%, transparent); + } + } + .bg-purple-800 { + background-color: var(--color-purple-800); + } + .bg-red-500 { + background-color: var(--color-red-500); + } + .bg-red-500\/90 { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 90%, transparent); + } + } + .bg-red-950\/50 { + background-color: color-mix(in srgb, oklch(25.8% 0.092 26.042) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-950) 50%, transparent); + } + } + .bg-transparent { + background-color: transparent; + } + .bg-white { + background-color: var(--color-white); + } + .bg-yellow-300 { + background-color: var(--color-yellow-300); + } + .bg-zinc-800 { + background-color: var(--color-zinc-800); + } + .bg-zinc-900\/30 { + background-color: color-mix(in srgb, oklch(21% 0.006 285.885) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-zinc-900) 30%, transparent); + } + } + .bg-zinc-900\/50 { + background-color: color-mix(in srgb, oklch(21% 0.006 285.885) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-zinc-900) 50%, transparent); + } + } + .p-0 { + padding: calc(var(--spacing) * 0); + } + .p-1 { + padding: calc(var(--spacing) * 1); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } + .px-1\.5 { + padding-inline: calc(var(--spacing) * 1.5); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-\[1px\] { + padding-block: 1px; + } + .py-\[3px\] { + padding-block: 3px; + } + .py-\[5px\] { + padding-block: 5px; + } + .pt-0 { + padding-top: calc(var(--spacing) * 0); + } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } + .pt-5 { + padding-top: calc(var(--spacing) * 5); + } + .pr-1 { + padding-right: calc(var(--spacing) * 1); + } + .pr-1\.5 { + padding-right: calc(var(--spacing) * 1.5); + } + .pr-2 { + padding-right: calc(var(--spacing) * 2); + } + .pr-2\.5 { + padding-right: calc(var(--spacing) * 2.5); + } + .pb-2 { + padding-bottom: calc(var(--spacing) * 2); + } + .pl-1 { + padding-left: calc(var(--spacing) * 1); + } + .pl-2 { + padding-left: calc(var(--spacing) * 2); + } + .pl-2\.5 { + padding-left: calc(var(--spacing) * 2.5); + } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } + .pl-5 { + padding-left: calc(var(--spacing) * 5); + } + .pl-6 { + padding-left: calc(var(--spacing) * 6); + } + .text-left { + text-align: left; + } + .font-mono { + font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[8px\] { + font-size: 8px; + } + .text-\[10px\] { + font-size: 10px; + } + .text-\[11px\] { + font-size: 11px; + } + .text-\[13px\] { + font-size: 13px; + } + .text-\[14px\] { + font-size: 14px; + } + .text-\[17px\] { + font-size: 17px; + } + .leading-6 { + --tw-leading: calc(var(--spacing) * 6); + line-height: calc(var(--spacing) * 6); + } + .leading-none { + --tw-leading: 1; + line-height: 1; + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .text-wrap { + text-wrap: wrap; + } + .break-words { + overflow-wrap: break-word; + } + .break-all { + word-break: break-all; + } + .whitespace-nowrap { + white-space: nowrap; + } + .whitespace-pre-wrap { + white-space: pre-wrap; + } + .text-\[\#4ade80\] { + color: #4ade80; + } + .text-\[\#5a5a5a\] { + color: #5a5a5a; + } + .text-\[\#6E6E77\] { + color: #6E6E77; + } + .text-\[\#6F6F78\] { + color: #6F6F78; + } + .text-\[\#8E61E3\] { + color: #8E61E3; + } + .text-\[\#666\] { + color: #666; + } + .text-\[\#888\] { + color: #888; + } + .text-\[\#999\] { + color: #999; + } + .text-\[\#7346a0\] { + color: #7346a0; + } + .text-\[\#65656D\] { + color: #65656D; + } + .text-\[\#737373\] { + color: #737373; + } + .text-\[\#A1A1AA\] { + color: #A1A1AA; + } + .text-\[\#A855F7\] { + color: #A855F7; + } + .text-\[\#E4E4E7\] { + color: #E4E4E7; + } + .text-\[\#d36cff\] { + color: #d36cff; + } + .text-\[\#f87171\] { + color: #f87171; + } + .text-black { + color: var(--color-black); + } + .text-gray-100 { + color: var(--color-gray-100); + } + .text-gray-300 { + color: var(--color-gray-300); + } + .text-gray-400 { + color: var(--color-gray-400); + } + .text-gray-500 { + color: var(--color-gray-500); + } + .text-green-500 { + color: var(--color-green-500); + } + .text-neutral-300 { + color: var(--color-neutral-300); + } + .text-neutral-400 { + color: var(--color-neutral-400); + } + .text-neutral-500 { + color: var(--color-neutral-500); + } + .text-purple-400 { + color: var(--color-purple-400); + } + .text-red-300 { + color: var(--color-red-300); + } + .text-red-400 { + color: var(--color-red-400); + } + .text-red-500 { + color: var(--color-red-500); + } + .text-white { + color: var(--color-white); + } + .text-white\/30 { + color: color-mix(in srgb, #fff 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-white) 30%, transparent); + } + } + .text-white\/70 { + color: color-mix(in srgb, #fff 70%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-white) 70%, transparent); + } + } + .text-yellow-300 { + color: var(--color-yellow-300); + } + .text-yellow-500 { + color: var(--color-yellow-500); + } + .text-zinc-200 { + color: var(--color-zinc-200); + } + .text-zinc-400 { + color: var(--color-zinc-400); + } + .text-zinc-500 { + color: var(--color-zinc-500); + } + .text-zinc-600 { + color: var(--color-zinc-600); + } + .uppercase { + text-transform: uppercase; + } + .italic { + font-style: italic; + } + .opacity-0 { + opacity: 0%; + } + .opacity-50 { + opacity: 50%; + } + .opacity-100 { + opacity: 100%; + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-1 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-white\/\[0\.08\] { + --tw-ring-color: color-mix(in srgb, #fff 8%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-white) 8%, transparent); + } + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-\[border-radius\] { + transition-property: border-radius; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-\[color\,transform\] { + transition-property: color,transform; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-\[max-height\] { + transition-property: max-height; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-\[opacity\] { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-opacity { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-none { + transition-property: none; + } + .delay-0 { + transition-delay: 0ms; + } + .delay-150 { + transition-delay: 150ms; + } + .delay-300 { + transition-delay: 300ms; + } + .\!duration-0 { + --tw-duration: 0ms !important; + transition-duration: 0ms !important; + } + .duration-0 { + --tw-duration: 0ms; + transition-duration: 0ms; + } + .duration-120 { + --tw-duration: 120ms; + transition-duration: 120ms; + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .ease-\[cubic-bezier\(0\.25\,0\.1\,0\.25\,1\)\] { + --tw-ease: cubic-bezier(0.25,0.1,0.25,1); + transition-timing-function: cubic-bezier(0.25,0.1,0.25,1); + } + .ease-in { + --tw-ease: var(--ease-in); + transition-timing-function: var(--ease-in); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } + .will-change-transform { + will-change: transform; + } + .select-none { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + } + .animation-delay-0 { + animation-delay: 0s; + } + .animation-delay-100 { + animation-delay: .1s; + } + .animation-delay-150 { + animation-delay: .15s; + } + .animation-delay-200 { + animation-delay: .2s; + } + .animation-delay-300 { + animation-delay: .3s; + } + .animation-delay-500 { + animation-delay: .5s; + } + .animation-delay-700 { + animation-delay: .7s; + } + .animation-delay-1000 { + animation-delay: 1s; + } + .animation-duration-0 { + animation-duration: 0s; + } + .animation-duration-100 { + animation-duration: .1s; + } + .animation-duration-200 { + animation-duration: .2s; + } + .animation-duration-300 { + animation-duration: .3s; + } + .animation-duration-500 { + animation-duration: .5s; + } + .animation-duration-700 { + animation-duration: .7s; + } + .animation-duration-1000 { + animation-duration: 1s; + } + .group-hover\:bg-\[\#5b2d89\] { + &:is(:where(.group):hover *) { + @media (hover: hover) { + background-color: #5b2d89; + } + } + } + .group-hover\:bg-\[\#6a6a6a\] { + &:is(:where(.group):hover *) { + @media (hover: hover) { + background-color: #6a6a6a; + } + } + } + .group-hover\:bg-\[\#21437982\] { + &:is(:where(.group):hover *) { + @media (hover: hover) { + background-color: #21437982; + } + } + } + .group-hover\:bg-\[\#efda1a2f\] { + &:is(:where(.group):hover *) { + @media (hover: hover) { + background-color: #efda1a2f; + } + } + } + .group-hover\:opacity-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } + .peer-hover\/bottom\:rounded-b-none { + &:is(:where(.peer\/bottom):hover ~ *) { + @media (hover: hover) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + } + } + .peer-hover\/left\:rounded-l-none { + &:is(:where(.peer\/left):hover ~ *) { + @media (hover: hover) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + .peer-hover\/right\:rounded-r-none { + &:is(:where(.peer\/right):hover ~ *) { + @media (hover: hover) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + .peer-hover\/top\:rounded-t-none { + &:is(:where(.peer\/top):hover ~ *) { + @media (hover: hover) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + } + .after\:absolute { + &::after { + content: var(--tw-content); + position: absolute; + } + } + .after\:inset-0 { + &::after { + content: var(--tw-content); + inset: calc(var(--spacing) * 0); + } + } + .after\:top-\[100\%\] { + &::after { + content: var(--tw-content); + top: 100%; + } + } + .after\:left-1\/2 { + &::after { + content: var(--tw-content); + left: calc(1 / 2 * 100%); + } + } + .after\:h-\[6px\] { + &::after { + content: var(--tw-content); + height: 6px; + } + } + .after\:w-\[10px\] { + &::after { + content: var(--tw-content); + width: 10px; + } + } + .after\:-translate-x-1\/2 { + &::after { + content: var(--tw-content); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .after\:animate-\[fadeOut_1s_ease-out_forwards\] { + &::after { + content: var(--tw-content); + animation: fadeOut 1s ease-out forwards; + } + } + .after\:border-t-\[6px\] { + &::after { + content: var(--tw-content); + border-top-style: var(--tw-border-style); + border-top-width: 6px; + } + } + .after\:border-r-\[5px\] { + &::after { + content: var(--tw-content); + border-right-style: var(--tw-border-style); + border-right-width: 5px; + } + } + .after\:border-l-\[5px\] { + &::after { + content: var(--tw-content); + border-left-style: var(--tw-border-style); + border-left-width: 5px; + } + } + .after\:border-t-white { + &::after { + content: var(--tw-content); + border-top-color: var(--color-white); + } + } + .after\:border-r-transparent { + &::after { + content: var(--tw-content); + border-right-color: transparent; + } + } + .after\:border-l-transparent { + &::after { + content: var(--tw-content); + border-left-color: transparent; + } + } + .after\:bg-purple-500\/30 { + &::after { + content: var(--tw-content); + background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-purple-500) 30%, transparent); + } + } + } + .after\:content-\[\"\"\] { + &::after { + --tw-content: ""; + content: var(--tw-content); + } + } + .focus-within\:border-\[\#454545\] { + &:focus-within { + border-color: #454545; + } + } + .hover\:bg-\[\#0f0f0f\] { + &:hover { + @media (hover: hover) { + background-color: #0f0f0f; + } + } + } + .hover\:bg-\[\#5f3f9a\]\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in oklab, #5f3f9a 20%, transparent); + } + } + } + .hover\:bg-\[\#5f3f9a\]\/40 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in oklab, #5f3f9a 40%, transparent); + } + } + } + .hover\:bg-\[\#18181B\] { + &:hover { + @media (hover: hover) { + background-color: #18181B; + } + } + } + .hover\:bg-\[\#34343b\] { + &:hover { + @media (hover: hover) { + background-color: #34343b; + } + } + } + .hover\:bg-red-600 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-600); + } + } + } + .hover\:bg-zinc-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-zinc-700); + } + } + } + .hover\:bg-zinc-800\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(27.4% 0.006 286.033) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-zinc-800) 50%, transparent); + } + } + } + } + .hover\:text-neutral-300 { + &:hover { + @media (hover: hover) { + color: var(--color-neutral-300); + } + } + } + .hover\:text-white { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } + } + } +} +* { + outline: none !important; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + border-radius: 10px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 10px; + background: rgba(255, 255, 255, 0.3); + } + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.4); + } + &::-webkit-scrollbar-corner { + background: transparent; + } +} +@-moz-document url-prefix() { + * { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.4) transparent; + scrollbar-width: 6px; + } +} +button { + &:hover { + @media (hover: hover) { + background-image: none; + } + } + --tw-outline-style: none; + outline-style: none; + --tw-border-style: none; + border-style: none; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + cursor: pointer; +} +input { + --tw-outline-style: none; + outline-style: none; + --tw-border-style: none; + border-style: none; + background-color: transparent; + background-image: none; + &::-moz-placeholder { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + &::placeholder { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + &::-moz-placeholder { + color: var(--color-neutral-500); + } + &::placeholder { + color: var(--color-neutral-500); + } + &::-moz-placeholder { + font-style: italic; + } + &::placeholder { + font-style: italic; + } + &:-moz-placeholder { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &:placeholder-shown { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} +svg { + height: auto; + width: auto; + pointer-events: none; +} +.with-data-text { + overflow: hidden; + &::before { + content: attr(data-text); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} +#react-scan-toolbar { + position: fixed; + top: calc(var(--spacing) * 0); + left: calc(var(--spacing) * 0); + display: flex; + flex-direction: column; + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; + font-size: 13px; + color: var(--color-white); + background-color: var(--color-black); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: move; + opacity: 0%; + z-index: 2147483678; + animation: fadeIn ease-in forwards; + animation-delay: .3s; + animation-duration: .3s; + --tw-shadow: 0 4px 12px var(--tw-shadow-color, rgba(0,0,0,0.2)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + place-self: start; + will-change: transform; + backface-visibility: hidden; +} +#react-scan-toolbar pre, +#react-scan-toolbar textarea, +#react-scan-toolbar input[type='text'], +#react-scan-toolbar input[type='search'], +#react-scan-toolbar [data-react-scan-selectable] { + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; + cursor: text; +} +.button { + &:hover { + background: rgba(255, 255, 255, 0.1); + } + &:active { + background: rgba(255, 255, 255, 0.15); + } +} +.resize-line-wrapper { + position: absolute; + overflow: hidden; +} +.resize-line { + position: absolute; + inset: calc(var(--spacing) * 0); + overflow: hidden; + background-color: var(--color-black); + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + svg { + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(1 / 2 * 100%); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.resize-right, +.resize-left { + inset-block: calc(var(--spacing) * 0); + width: calc(var(--spacing) * 6); + cursor: ew-resize; + .resize-line-wrapper { + inset-block: calc(var(--spacing) * 0); + width: calc(1 / 2 * 100%); + } + &:hover { + .resize-line { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } +} +.resize-right { + right: calc(var(--spacing) * 0); + --tw-translate-x: calc(1 / 2 * 100%); + translate: var(--tw-translate-x) var(--tw-translate-y); + .resize-line-wrapper { + right: calc(var(--spacing) * 0); + } + .resize-line { + border-top-right-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.resize-left { + left: calc(var(--spacing) * 0); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + .resize-line-wrapper { + left: calc(var(--spacing) * 0); + } + .resize-line { + border-top-left-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-lg); + --tw-translate-x: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.resize-top, +.resize-bottom { + inset-inline: calc(var(--spacing) * 0); + height: calc(var(--spacing) * 6); + cursor: ns-resize; + .resize-line-wrapper { + inset-inline: calc(var(--spacing) * 0); + height: calc(1 / 2 * 100%); + } + &:hover { + .resize-line { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } +} +.resize-top { + top: calc(var(--spacing) * 0); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + .resize-line-wrapper { + top: calc(var(--spacing) * 0); + } + .resize-line { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + --tw-translate-y: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.resize-bottom { + bottom: calc(var(--spacing) * 0); + --tw-translate-y: calc(1 / 2 * 100%); + translate: var(--tw-translate-x) var(--tw-translate-y); + .resize-line-wrapper { + bottom: calc(var(--spacing) * 0); + } + .resize-line { + border-bottom-right-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-lg); + --tw-translate-y: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.react-scan-header { + display: flex; + align-items: center; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + padding-right: calc(var(--spacing) * 2); + padding-left: calc(var(--spacing) * 3); + min-height: calc(var(--spacing) * 9); + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + border-color: #222; + overflow: hidden; + white-space: nowrap; +} +.react-scan-replay-button, +.react-scan-close-button { + display: flex; + align-items: center; + padding: calc(var(--spacing) * 1); + min-width: -moz-fit-content; + min-width: fit-content; + border-radius: 4px; + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; +} +.react-scan-replay-button { + position: relative; + overflow: hidden; + background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 50%, transparent) !important; + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-purple-500) 50%, transparent) !important; + } + &:hover { + background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 25%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-purple-500) 25%, transparent); + } + } + &.disabled { + opacity: 50%; + pointer-events: none; + } + &:before { + content: ""; + position: absolute; + inset: calc(var(--spacing) * 0); + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + animation: shimmer 2s infinite; + background: linear-gradient( + to right, + transparent, + rgba(142, 97, 227, 0.3), + transparent + ); + } +} +.react-scan-close-button { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + &:hover { + background-color: color-mix(in srgb, #fff 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 15%, transparent); + } + } +} +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} +.react-section-header { + position: sticky; + z-index: 100; + display: flex; + align-items: center; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 7); + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #888; + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + border-color: #222; + background-color: #0a0a0a; +} +.react-scan-section { + display: flex; + flex-direction: column; + padding-inline: calc(var(--spacing) * 2); + color: #888; + &::before { + content: var(--tw-content); + color: var(--color-gray-500); + } + &::before { + --tw-content: attr(data-section); + content: var(--tw-content); + } + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + > .react-scan-property { + margin-left: calc(14px * -1); + } +} +.react-scan-property { + position: relative; + display: flex; + flex-direction: column; + padding-left: calc(var(--spacing) * 8); + border-left-style: var(--tw-border-style); + border-left-width: 1px; + border-color: transparent; + overflow: hidden; +} +.react-scan-property-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: calc(var(--spacing) * 7); + max-width: 100%; + overflow: hidden; +} +.react-scan-string { + color: #9ecbff; +} +.react-scan-number { + color: #79c7ff; +} +.react-scan-boolean { + color: #56b6c2; +} +.react-scan-key { + width: -moz-fit-content; + width: fit-content; + max-width: calc(var(--spacing) * 60); + white-space: nowrap; + color: var(--color-white); +} +.react-scan-input { + color: var(--color-white); + background-color: var(--color-black); +} +@keyframes blink { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +.react-scan-arrow { + position: absolute; + top: calc(var(--spacing) * 0); + left: calc(var(--spacing) * 7); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + height: calc(var(--spacing) * 7); + width: calc(var(--spacing) * 6); + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + z-index: 10; + > svg { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } +} +.react-scan-nested { + position: relative; + overflow: hidden; + &:before { + content: ""; + position: absolute; + top: calc(var(--spacing) * 0); + left: calc(var(--spacing) * 0); + height: 100%; + width: 1px; + background-color: color-mix(in srgb, oklch(55.1% 0.027 264.364) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-gray-500) 30%, transparent); + } + } +} +.react-scan-settings { + position: absolute; + inset: calc(var(--spacing) * 0); + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 4); + padding-inline: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 2); + color: #888; + > div { + display: flex; + align-items: center; + justify-content: space-between; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; + } +} +.react-scan-preview-line { + position: relative; + display: flex; + min-height: calc(var(--spacing) * 7); + align-items: center; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); +} +.react-scan-flash-overlay { + position: absolute; + inset: calc(var(--spacing) * 0); + opacity: 0%; + z-index: 50; + pointer-events: none; + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + mix-blend-mode: multiply; + background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-purple-500) 90%, transparent); + } +} +.react-scan-toggle { + position: relative; + display: inline-flex; + height: calc(var(--spacing) * 6); + width: calc(var(--spacing) * 10); + input { + position: absolute; + inset: calc(var(--spacing) * 0); + z-index: 20; + opacity: 0%; + cursor: pointer; + height: 100%; + width: 100%; + } + input:checked { + + div { + background-color: #5f3f9a; + &::before { + --tw-translate-x: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + left: auto; + border-color: #5f3f9a; + } + } + } + > div { + position: absolute; + inset: calc(var(--spacing) * 1); + background-color: var(--color-neutral-700); + border-radius: calc(infinity * 1px); + pointer-events: none; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; + &:before { + --tw-content: ''; + content: var(--tw-content); + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(var(--spacing) * 0); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + height: calc(var(--spacing) * 4); + width: calc(var(--spacing) * 4); + background-color: var(--color-white); + border-style: var(--tw-border-style); + border-width: 2px; + border-color: var(--color-neutral-700); + border-radius: calc(infinity * 1px); + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; + } + } +} +.react-scan-flash-active { + opacity: 40%; + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; +} +.react-scan-inspector-overlay { + display: flex; + flex-direction: column; + opacity: 0%; + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 200ms; + transition-duration: 200ms; + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + will-change: opacity; + &.fade-out { + opacity: 0%; + } + &.fade-in { + opacity: 100%; + } +} +.react-scan-what-changed { + ul { + list-style-type: disc; + padding-left: calc(var(--spacing) * 4); + } + li { + white-space: nowrap; + > div { + display: flex; + align-items: center; + justify-content: space-between; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + } + } +} +.count-badge { + display: flex; + align-items: center; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + padding-inline: calc(var(--spacing) * 1.5); + padding-block: calc(var(--spacing) * 0.5); + border-radius: 4px; + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + color: #a855f7; + --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + background-color: color-mix(in oklab, #a855f7 10%, transparent); + transform-origin: center; + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + transition-delay: 150ms; + --tw-duration: 300ms; + transition-duration: 300ms; +} +.count-flash { + animation: countFlash .3s ease-out forwards; +} +.count-flash-white { + animation: countFlashShake .3s ease-out forwards; + transition-delay: 500ms !important; +} +.change-scope { + display: flex; + align-items: center; + -moz-column-gap: calc(var(--spacing) * 1); + column-gap: calc(var(--spacing) * 1); + color: #666; + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; + > div { + padding-inline: calc(var(--spacing) * 1.5); + padding-block: calc(var(--spacing) * 0.5); + border-radius: 4px; + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + transform-origin: center; + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + transition-delay: 150ms; + --tw-duration: 300ms; + transition-duration: 300ms; + &[data-flash="true"] { + background-color: color-mix(in oklab, #a855f7 10%, transparent); + color: #a855f7; + } + } +} +.react-scan-slider { + position: relative; + min-height: calc(var(--spacing) * 6); + > input { + position: absolute; + inset: calc(var(--spacing) * 0); + opacity: 0%; + } + &:before { + --tw-content: ''; + content: var(--tw-content); + position: absolute; + inset-inline: calc(var(--spacing) * 0); + top: calc(1 / 2 * 100%); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + height: calc(var(--spacing) * 1.5); + background-color: color-mix(in oklab, #8e61e3 40%, transparent); + border-radius: var(--radius-lg); + pointer-events: none; + } + &:after { + --tw-content: ''; + content: var(--tw-content); + position: absolute; + inset-inline: calc(var(--spacing) * 0); + inset-block: calc(var(--spacing) * -2); + z-index: calc(10 * -1); + } + span { + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(var(--spacing) * 0); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + height: calc(var(--spacing) * 2.5); + width: calc(var(--spacing) * 2.5); + border-radius: var(--radius-lg); + background-color: #8e61e3; + pointer-events: none; + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 75ms; + transition-duration: 75ms; + } +} +.resize-v-line { + display: flex; + align-items: center; + justify-content: center; + max-width: calc(var(--spacing) * 1); + min-width: calc(var(--spacing) * 1); + height: 100%; + width: 100%; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover, + &:active { + > span { + background-color: #222; + } + svg { + opacity: 100%; + } + } + &::before { + --tw-content: ""; + content: var(--tw-content); + position: absolute; + inset: calc(var(--spacing) * 0); + left: calc(1 / 2 * 100%); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + width: 1px; + background-color: #222; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + > span { + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(1 / 2 * 100%); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + height: 18px; + width: calc(var(--spacing) * 1.5); + border-radius: 4px; + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + svg { + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(1 / 2 * 100%); + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + rotate: 90deg; + color: var(--color-neutral-400); + opacity: 0%; + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + z-index: 50; + } +} +.tree-node-search-highlight { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + span { + padding-block: 1px; + border-radius: var(--radius-sm); + background-color: var(--color-yellow-300); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + color: var(--color-black); + } + .single { + margin-right: 1px; + padding-inline: 2px; + } + .regex { + padding-inline: 2px; + } + .start { + margin-left: 1px; + border-top-left-radius: var(--radius-sm); + border-bottom-left-radius: var(--radius-sm); + } + .end { + margin-right: 1px; + border-top-right-radius: var(--radius-sm); + border-bottom-right-radius: var(--radius-sm); + } + .middle { + margin-inline: 1px; + border-radius: var(--radius-sm); + } +} +.react-scan-toolbar-notification { + position: absolute; + inset-inline: calc(var(--spacing) * 0); + display: flex; + align-items: center; + -moz-column-gap: calc(var(--spacing) * 2); + column-gap: calc(var(--spacing) * 2); + padding: calc(var(--spacing) * 1); + padding-left: calc(var(--spacing) * 2); + font-size: 10px; + color: var(--color-neutral-300); + background-color: color-mix(in srgb, #000 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 90%, transparent); + } + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:before { + --tw-content: ''; + content: var(--tw-content); + position: absolute; + inset-inline: calc(var(--spacing) * 0); + background-color: var(--color-black); + height: calc(var(--spacing) * 2); + } + &.position-top { + top: 100%; + --tw-translate-y: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + border-bottom-right-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-lg); + &::before { + top: calc(var(--spacing) * 0); + --tw-translate-y: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + &.position-bottom { + bottom: 100%; + --tw-translate-y: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + &::before { + bottom: calc(var(--spacing) * 0); + --tw-translate-y: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + &.is-open { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.react-scan-header-item { + position: absolute; + inset: calc(var(--spacing) * 0); + --tw-translate-y: calc(200% * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 300ms; + transition-duration: 300ms; + &.is-visible { + --tw-translate-y: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } +} +.react-scan-components-tree:has(.resize-v-line:hover, .resize-v-line:active) + .tree { + overflow: hidden; +} +.react-scan-expandable { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 75ms; + transition-duration: 75ms; + transition-timing-function: ease-out; + > * { + min-height: 0; + } + &.react-scan-expanded { + grid-template-rows: 1fr; + transition-duration: 100ms; + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@property --tw-content { + syntax: "*"; + initial-value: ""; + inherits: false; +} +@property --tw-ordinal { + syntax: "*"; + inherits: false; +} +@property --tw-slashed-zero { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-figure { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false; +} +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes countFlash { + 0% { + background-color: rgba(168, 85, 247, 0.3); + transform: scale(1.05); + } + 100% { + background-color: rgba(168, 85, 247, 0.1); + transform: scale(1); + } +} +@keyframes countFlashShake { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 50% { + transform: translateX(5px) scale(1.1); + } + 75% { + transform: translateX(-5px); + } + 100% { + transform: translateX(0); + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + --tw-ease: initial; + --tw-content: ""; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; + } + } +} diff --git a/packages/scan-react/src/web/assets/css/styles.tailwind.css b/packages/scan-react/src/web/assets/css/styles.tailwind.css new file mode 100644 index 00000000..445e8194 --- /dev/null +++ b/packages/scan-react/src/web/assets/css/styles.tailwind.css @@ -0,0 +1,696 @@ +@import "tailwindcss"; +@config "../../../../tailwind.config.mjs"; + +* { + outline: none !important; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* WebKit (Chrome, Safari, Edge) specific scrollbar styles */ + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + border-radius: 10px; + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 10px; + background: rgba(255, 255, 255, 0.3); + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.4); + } + + &::-webkit-scrollbar-corner { + background: transparent; + } +} + +@-moz-document url-prefix() { + * { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.4) transparent; + scrollbar-width: 6px; + } +} + +button { + @apply hover:bg-none; + @apply outline-none; + @apply border-none; + @apply transition-colors ease-out; + @apply cursor-pointer; +} + +input { + @apply outline-none; + @apply border-none; + @apply bg-none bg-transparent; + @apply placeholder:text-neutral-500 placeholder:italic placeholder:text-xs; + @apply placeholder-shown:truncate; +} + +svg { + @apply w-auto h-auto; + @apply pointer-events-none; +} + +/* + Using CSS content with data attributes is more performant than: + 1. React re-renders with JSX text content + 2. Direct DOM manipulation methods: + - element.textContent (creates/updates text nodes, triggers repaint) + - element.innerText (triggers reflow by computing styles & layout) + - element.innerHTML (heavy parsing, triggers reflow, security risks) + 3. Multiple data attributes with complex CSS concatenation + + This approach: + - Avoids React reconciliation + - Uses browser's native CSS engine (optimized content updates) + - Minimizes main thread work + - Reduces DOM operations + - Avoids forced reflows (layout recalculation) + - Only triggers necessary repaints + - Keeps pseudo-element updates in render layer +*/ +.with-data-text { + overflow: hidden; + &::before { + content: attr(data-text); + @apply block; + @apply truncate; + } +} + +#react-scan-toolbar { + @apply fixed left-0 top-0; + @apply flex flex-col; + @apply shadow-lg; + @apply font-mono text-[13px] text-white; + @apply bg-black; + @apply select-none; + @apply cursor-move; + @apply opacity-0; + @apply z-[2147483678]; + @apply animate-fade-in animation-duration-300 animation-delay-300; + @apply shadow-[0_4px_12px_rgba(0,0,0,0.2)]; + @apply place-self-start; + + will-change: transform; + backface-visibility: hidden; +} + +/* The toolbar uses select-none for drag UX, so re-enable text selection on + * content elements (e.g. AI prompt
, search inputs). See #415. */
+#react-scan-toolbar pre,
+#react-scan-toolbar textarea,
+#react-scan-toolbar input[type='text'],
+#react-scan-toolbar input[type='search'],
+#react-scan-toolbar [data-react-scan-selectable] {
+  @apply select-text;
+  cursor: text;
+}
+
+.button {
+  &:hover {
+    background: rgba(255, 255, 255, 0.1);
+  }
+
+  &:active {
+    background: rgba(255, 255, 255, 0.15);
+  }
+}
+
+.resize-line-wrapper {
+  @apply absolute;
+  @apply overflow-hidden;
+}
+
+.resize-line {
+  @apply absolute inset-0;
+  @apply overflow-hidden;
+  @apply bg-black;
+  @apply transition-all;
+
+  svg {
+    @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
+  }
+}
+
+.resize-right,
+.resize-left {
+  @apply inset-y-0;
+  @apply w-6;
+  @apply cursor-ew-resize;
+
+  .resize-line-wrapper {
+    @apply inset-y-0;
+    @apply w-1/2;
+  }
+
+  &:hover {
+    .resize-line {
+      @apply translate-x-0;
+    }
+  }
+}
+.resize-right {
+  @apply right-0;
+  @apply translate-x-1/2;
+
+  .resize-line-wrapper {
+    @apply right-0;
+  }
+  .resize-line {
+    @apply rounded-r-lg;
+    @apply -translate-x-full;
+  }
+}
+
+.resize-left {
+  @apply left-0;
+  @apply -translate-x-1/2;
+
+  .resize-line-wrapper {
+    @apply left-0;
+  }
+  .resize-line {
+    @apply rounded-l-lg;
+    @apply translate-x-full;
+  }
+}
+
+.resize-top,
+.resize-bottom {
+  @apply inset-x-0;
+  @apply h-6;
+  @apply cursor-ns-resize;
+
+  .resize-line-wrapper {
+    @apply inset-x-0;
+    @apply h-1/2;
+  }
+
+  &:hover {
+    .resize-line {
+      @apply translate-y-0;
+    }
+  }
+}
+.resize-top {
+  @apply top-0;
+  @apply -translate-y-1/2;
+
+  .resize-line-wrapper {
+    @apply top-0;
+  }
+  .resize-line {
+    @apply rounded-t-lg;
+    @apply translate-y-full;
+  }
+}
+
+.resize-bottom {
+  @apply bottom-0;
+  @apply translate-y-1/2;
+
+  .resize-line-wrapper {
+    @apply bottom-0;
+  }
+  .resize-line {
+    @apply rounded-b-lg;
+    @apply -translate-y-full;
+  }
+}
+
+.react-scan-header {
+  @apply flex items-center gap-x-2;
+  @apply pl-3 pr-2;
+  @apply min-h-9;
+  @apply border-b-1 border-[#222];
+  @apply whitespace-nowrap overflow-hidden;
+}
+
+.react-scan-replay-button,
+.react-scan-close-button {
+  @apply flex items-center;
+  @apply p-1;
+  @apply min-w-fit;
+  @apply rounded;
+  @apply transition-all duration-300;
+}
+
+.react-scan-replay-button {
+  @apply relative;
+  @apply overflow-hidden;
+  @apply !bg-purple-500/50;
+
+  &:hover {
+    @apply bg-purple-500/25;
+  }
+
+  &.disabled {
+    @apply opacity-50;
+    @apply pointer-events-none;
+  }
+
+  &:before {
+    content: "";
+    @apply absolute;
+    @apply inset-0;
+    @apply -translate-x-full;
+    animation: shimmer 2s infinite;
+    background: linear-gradient(
+      to right,
+      transparent,
+      rgba(142, 97, 227, 0.3),
+      transparent
+    );
+  }
+}
+
+.react-scan-close-button {
+  @apply bg-white/10;
+
+  &:hover {
+    @apply bg-white/15;
+  }
+}
+
+@keyframes shimmer {
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+.react-section-header {
+  @apply sticky z-100;
+  @apply flex items-center gap-x-2;
+  @apply px-3;
+  @apply w-full h-7;
+  @apply text-[#888] truncate;
+  @apply bg-[#0a0a0a] border-b-1 border-[#222];
+}
+
+.react-scan-section {
+  @apply flex flex-col;
+  @apply px-2;
+  @apply text-[#888];
+  @apply before:content-[attr(data-section)] before:text-gray-500;
+  @apply text-xs;
+
+  > .react-scan-property {
+    @apply -ml-3.5;
+  }
+}
+
+.react-scan-property {
+  @apply relative;
+  @apply flex flex-col;
+  @apply pl-8;
+  @apply border-l-1 border-transparent;
+  @apply overflow-hidden;
+}
+
+.react-scan-property-content {
+  @apply flex-1 flex flex-col;
+  @apply min-h-7;
+  @apply max-w-full;
+  @apply overflow-hidden;
+}
+
+.react-scan-string {
+  color: #9ecbff;
+}
+
+.react-scan-number {
+  color: #79c7ff;
+}
+
+.react-scan-boolean {
+  color: #56b6c2;
+}
+
+.react-scan-key {
+  @apply w-fit max-w-60;
+  @apply text-white whitespace-nowrap;
+}
+
+.react-scan-input {
+  @apply text-white;
+  @apply bg-black;
+}
+
+@keyframes blink {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+
+.react-scan-arrow {
+  @apply absolute top-0 left-7;
+  @apply flex items-center justify-center;
+  @apply cursor-pointer;
+  @apply w-6 h-7;
+  @apply -translate-x-full;
+  @apply z-10;
+
+  > svg {
+    @apply transition-transform;
+  }
+}
+
+.react-scan-nested {
+  @apply relative;
+  @apply overflow-hidden;
+
+  &:before {
+    content: "";
+    @apply absolute top-0 left-0;
+    @apply w-[1px] h-full;
+    @apply bg-gray-500/30;
+  }
+}
+
+.react-scan-settings {
+  @apply absolute inset-0;
+  @apply flex flex-col gap-4;
+  @apply py-2 px-4;
+  @apply text-[#888];
+
+  > div {
+    @apply flex items-center justify-between;
+    @apply transition-colors duration-300;
+  }
+}
+
+.react-scan-preview-line {
+  @apply relative;
+  @apply flex items-center min-h-7 gap-x-2;
+}
+
+.react-scan-flash-overlay {
+  @apply absolute inset-0;
+  @apply opacity-0;
+  @apply z-50;
+  @apply pointer-events-none;
+  @apply transition-opacity;
+  @apply mix-blend-multiply;
+  @apply bg-purple-500/90;
+}
+
+.react-scan-toggle {
+  @apply relative;
+  @apply inline-flex;
+  @apply w-10 h-6;
+
+  input {
+    @apply absolute inset-0;
+    @apply opacity-0 z-20;
+    @apply cursor-pointer;
+    @apply w-full h-full;
+  }
+
+  input:checked {
+    + div {
+      @apply bg-[#5f3f9a];
+
+      &::before {
+        @apply translate-x-full;
+        @apply left-auto;
+        @apply border-[#5f3f9a];
+      }
+    }
+  }
+
+  > div {
+    @apply absolute inset-1;
+    @apply bg-neutral-700;
+    @apply rounded-full;
+    @apply pointer-events-none;
+    @apply transition-colors duration-300;
+
+    &:before {
+      @apply content-[''];
+      @apply absolute top-1/2 left-0;
+      @apply -translate-y-1/2;
+      @apply w-4 h-4;
+      @apply bg-white;
+      @apply border-2 border-neutral-700;
+      @apply rounded-full;
+      @apply shadow-sm;
+      @apply transition-all duration-300;
+    }
+  }
+}
+
+.react-scan-flash-active {
+  @apply opacity-40;
+  @apply transition-opacity duration-300;
+}
+
+.react-scan-inspector-overlay {
+  @apply flex flex-col;
+  @apply opacity-0;
+  @apply transition-opacity duration-200 ease-out;
+  will-change: opacity;
+
+  &.fade-out {
+    @apply opacity-0;
+  }
+
+  &.fade-in {
+    @apply opacity-100;
+  }
+}
+
+.react-scan-what-changed {
+  ul {
+    @apply list-disc;
+    @apply pl-4;
+  }
+
+  li {
+    @apply whitespace-nowrap;
+    > div {
+      @apply flex items-center justify-between gap-x-2;
+    }
+  }
+}
+
+.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];
+  @apply bg-[#a855f7]/10;
+  @apply origin-center;
+  @apply transition-all duration-300 delay-150;
+}
+
+.count-flash {
+  @apply animate-count-flash;
+}
+
+.count-flash-white {
+  @apply animate-count-flash-shake !delay-500;
+}
+
+.change-scope {
+  @apply flex items-center gap-x-1;
+  @apply text-[#666];
+  @apply text-xs;
+  @apply font-mono;
+
+  > div {
+    @apply px-1.5 py-0.5;
+    @apply text-xs font-medium tabular-nums rounded-[4px];
+    @apply origin-center;
+    @apply transition-all duration-300 delay-150;
+
+    &[data-flash="true"] {
+      @apply bg-[#a855f7]/10 text-[#a855f7];
+    }
+  }
+}
+
+.react-scan-slider {
+  @apply relative;
+  @apply min-h-6;
+
+  > input {
+    @apply absolute inset-0;
+    @apply opacity-0;
+  }
+
+  &:before {
+    @apply content-[''];
+    @apply absolute inset-x-0 top-1/2 -translate-y-1/2;
+    @apply h-1.5;
+    @apply bg-[#8e61e3]/40;
+    @apply rounded-lg;
+    @apply pointer-events-none;
+  }
+
+  &:after {
+    @apply content-[''];
+    @apply absolute inset-x-0 -inset-y-2;
+    @apply -z-10;
+  }
+
+  span {
+    @apply absolute left-0 top-1/2 -translate-y-1/2;
+    @apply w-2.5 h-2.5;
+    @apply rounded-lg;
+    @apply bg-[#8e61e3];
+    @apply pointer-events-none;
+    @apply transition-transform duration-75;
+  }
+}
+
+.resize-v-line {
+  @apply flex items-center justify-center;
+  @apply min-w-1 max-w-1;
+  @apply w-full h-full;
+  @apply transition-colors;
+
+  &:hover,
+  &:active {
+    > span {
+      @apply bg-[#222];
+    }
+
+    svg {
+      @apply opacity-100;
+    }
+  }
+
+  &::before {
+    @apply content-[""];
+    @apply absolute inset-0 left-1/2 -translate-x-1/2;
+    @apply w-[1px];
+    @apply bg-[#222];
+    @apply transition-colors;
+  }
+
+  > span {
+    @apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2;
+    @apply w-1.5 h-4.5;
+    @apply rounded;
+    @apply transition-colors;
+  }
+
+  svg {
+    @apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2;
+    @apply text-neutral-400 rotate-90;
+    @apply opacity-0;
+    @apply transition-opacity;
+    @apply z-50;
+  }
+}
+
+.tree-node-search-highlight {
+  @apply truncate;
+
+  span {
+    @apply py-[1px];
+    @apply font-medium bg-yellow-300 text-black rounded-sm;
+  }
+
+  .single {
+    @apply px-[2px] mr-[1px];
+  }
+
+  .regex {
+    @apply px-[2px];
+  }
+
+  .start {
+    @apply rounded-l-sm ml-[1px];
+  }
+
+  .end {
+    @apply rounded-r-sm mr-[1px];
+  }
+
+  .middle {
+    @apply rounded-sm mx-[1px];
+  }
+}
+
+.react-scan-toolbar-notification {
+  @apply absolute inset-x-0;
+  @apply flex items-center gap-x-2;
+  @apply p-1 pl-2 text-[10px];
+  @apply text-neutral-300;
+  @apply bg-black/90;
+  @apply transition-transform;
+
+  &:before {
+    @apply content-[''];
+    @apply absolute inset-x-0;
+    @apply bg-black;
+    @apply h-2;
+  }
+
+  &.position-top {
+    @apply top-full -translate-y-full;
+    @apply rounded-b-lg;
+
+    &::before {
+      @apply top-0 -translate-y-full;
+    }
+  }
+
+  &.position-bottom {
+    @apply bottom-full translate-y-full;
+    @apply rounded-t-lg;
+
+    &::before {
+      @apply bottom-0 translate-y-full;
+    }
+  }
+
+  &.is-open {
+    @apply translate-y-0;
+  }
+}
+
+.react-scan-header-item {
+  @apply absolute inset-0 -translate-y-[200%];
+  @apply transition-transform duration-300;
+
+  &.is-visible {
+    @apply translate-y-0;
+  }
+}
+
+.react-scan-components-tree:has(.resize-v-line:hover, .resize-v-line:active)
+  .tree {
+  overflow: hidden;
+}
+
+.react-scan-expandable {
+  display: grid;
+  grid-template-rows: 0fr;
+  @apply overflow-hidden;
+  @apply transition-all duration-75;
+  transition-timing-function: ease-out;
+
+  > * {
+    min-height: 0;
+  }
+
+  &.react-scan-expanded {
+    grid-template-rows: 1fr;
+    transition-duration: 100ms;
+  }
+}
diff --git a/packages/scan-react/src/web/components/copy-to-clipboard/index.tsx b/packages/scan-react/src/web/components/copy-to-clipboard/index.tsx
new file mode 100644
index 00000000..aca59969
--- /dev/null
+++ b/packages/scan-react/src/web/components/copy-to-clipboard/index.tsx
@@ -0,0 +1,85 @@
+import type { JSX } from 'react';
+import { memo, useCallback, useEffect, useState } from 'react';
+import { cn } from '~web/utils/helpers';
+import { Icon } from '../icon';
+
+interface CopyToClipboardProps {
+  text: string;
+  children?: (props: {
+    ClipboardIcon: JSX.Element;
+    onClick: (e: React.MouseEvent) => void;
+  }) => JSX.Element;
+  onCopy?: (success: boolean, text: string) => void;
+  className?: string;
+  iconSize?: number;
+}
+
+export const CopyToClipboard = /* @__PURE__ */ memo(
+  ({
+    text,
+    children,
+    onCopy,
+    className,
+    iconSize = 14,
+  }: CopyToClipboardProps): JSX.Element => {
+    const [isCopied, setIsCopied] = useState(false);
+
+    useEffect(() => {
+      if (isCopied) {
+        const timeout = setTimeout(() => setIsCopied(false), 600);
+        return () => {
+          clearTimeout(timeout);
+        };
+      }
+    }, [isCopied]);
+
+    const copyToClipboard = useCallback(
+      (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+
+        navigator.clipboard.writeText(text).then(
+          () => {
+            setIsCopied(true);
+            onCopy?.(true, text);
+          },
+          () => {
+            onCopy?.(false, text);
+          },
+        );
+      },
+      [text, onCopy],
+    );
+
+    const ClipboardIcon = (
+      
+    );
+
+    if (!children) {
+      return ClipboardIcon;
+    }
+
+    return children({
+      ClipboardIcon,
+      onClick: copyToClipboard,
+    });
+  },
+);
diff --git a/packages/scan-react/src/web/components/icon/index.tsx b/packages/scan-react/src/web/components/icon/index.tsx
new file mode 100644
index 00000000..cf623003
--- /dev/null
+++ b/packages/scan-react/src/web/components/icon/index.tsx
@@ -0,0 +1,47 @@
+import { type CSSProperties, type ForwardedRef, forwardRef } from 'react';
+
+export interface SVGIconProps {
+  size?: number | Array;
+  name: string;
+  fill?: string;
+  stroke?: string;
+  className?: string;
+  externalURL?: string;
+  style?: CSSProperties;
+}
+
+export const Icon = forwardRef(({
+  size = 15,
+  name,
+  fill = 'currentColor',
+  stroke = 'currentColor',
+  className,
+  externalURL = '',
+  style,
+}: SVGIconProps, ref: ForwardedRef) => {
+  const width = Array.isArray(size) ? size[0] : size;
+  const height = Array.isArray(size) ? size[1] || size[0] : size;
+
+  const path = `${externalURL}#${name}`;
+
+  return (
+    
+      {name}
+      
+    
+  );
+});
diff --git a/packages/scan-react/src/web/components/svg-sprite/index.tsx b/packages/scan-react/src/web/components/svg-sprite/index.tsx
new file mode 100644
index 00000000..6680cb3f
--- /dev/null
+++ b/packages/scan-react/src/web/components/svg-sprite/index.tsx
@@ -0,0 +1,112 @@
+export const SvgSprite = () => {
+  return (
+    
+      React Scan Icons
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+      
+
+      
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+        
+        
+        
+        
+        
+      
+
+      
+        
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+      
+
+      
+        
+      
+
+      
+        
+      
+
+      
+        
+      
+
+      
+        
+        
+        
+      
+
+      
+        
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+        
+      
+
+      
+        
+        
+        
+        
+      
+    
+  )
+};
diff --git a/packages/scan-react/src/web/components/toggle/index.tsx b/packages/scan-react/src/web/components/toggle/index.tsx
new file mode 100644
index 00000000..9f340439
--- /dev/null
+++ b/packages/scan-react/src/web/components/toggle/index.tsx
@@ -0,0 +1,23 @@
+import type { InputHTMLAttributes } from 'react';
+import { cn } from '~web/utils/helpers';
+
+interface ToggleProps extends InputHTMLAttributes {
+  checked: boolean;
+  onChange: ((e: React.ChangeEvent) => void);
+  className?: string;
+};
+
+export const Toggle = ({
+  className,
+  ...props
+}: ToggleProps) => {
+  return (
+    
+ +
+
+ ); +}; diff --git a/packages/scan-react/src/web/constants.ts b/packages/scan-react/src/web/constants.ts new file mode 100644 index 00000000..52ec0b96 --- /dev/null +++ b/packages/scan-react/src/web/constants.ts @@ -0,0 +1,19 @@ +export const SAFE_AREA = 24; +export const COPY_FEEDBACK_DURATION_MS = 600; +export const MIN_SIZE = { + width: 550, + height: 350, + initialHeight: 400, +} as const; + +export const MIN_CONTAINER_WIDTH = 240; + +export const LOCALSTORAGE_KEY = "react-scan-widget-settings-v2"; +export const LOCALSTORAGE_COLLAPSED_KEY = "react-scan-widget-collapsed-v1"; +export const LOCALSTORAGE_LAST_VIEW_KEY = "react-scan-widget-last-view-v1"; + +// CSS selector for elements inside #react-scan-toolbar that should NOT +// trigger drag and SHOULD allow native text selection / focus (#415). +// Keep in sync with the matching CSS rule in styles.tailwind.css. +export const TOOLBAR_INTERACTIVE_SELECTOR = + "button, a, input, textarea, select, pre, [contenteditable], [data-react-scan-selectable]"; diff --git a/packages/scan-react/src/web/hooks/use-delayed-value.ts b/packages/scan-react/src/web/hooks/use-delayed-value.ts new file mode 100644 index 00000000..9f2a9a50 --- /dev/null +++ b/packages/scan-react/src/web/hooks/use-delayed-value.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; + +/** + * Delays a boolean value change by a specified duration. + * Perfect for coordinating animations with state changes. + * + * @param {boolean} value - The boolean value to delay + * @param {number} onDelay - Milliseconds to wait before changing to true + * @param {number} [offDelay] - Milliseconds to wait before changing to false (defaults to onDelay) + * @returns {boolean} The delayed value + * + * @example + * // Delay both transitions by 300ms + * const isVisible = useDelayedValue(show, 300); + * + * @example + * // Quick show (100ms), slow hide (500ms) + * const isVisible = useDelayedValue(show, 100, 500); + * + * @example + * // Use with CSS transitions + * const isVisible = useDelayedValue(show, 300); + * return ( + *
+ * {content} + *
+ * ); + */ +export const useDelayedValue = ( + value: boolean, + onDelay: number, + offDelay: number = onDelay, +): boolean => { + const [delayedValue, setDelayedValue] = useState(value); + + /* + * oxlint-disable-next-line react-hooks/exhaustive-deps + * delayedValue is intentionally omitted to prevent unnecessary timeouts + * and used only in the early return check + */ + useEffect(() => { + if (value === delayedValue) return; + + const delay = value ? onDelay : offDelay; + const timeout = setTimeout(() => setDelayedValue(value), delay); + + return () => clearTimeout(timeout); + }, [value, onDelay, offDelay]); + + return delayedValue; +}; diff --git a/packages/scan-react/src/web/hooks/use-virtual-list.ts b/packages/scan-react/src/web/hooks/use-virtual-list.ts new file mode 100644 index 00000000..bcc01174 --- /dev/null +++ b/packages/scan-react/src/web/hooks/use-virtual-list.ts @@ -0,0 +1,117 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export interface VirtualItem { + key: number; + index: number; + start: number; +} + +export const useVirtualList = (options: { + count: number; + getScrollElement: () => HTMLElement | null; + estimateSize: () => number; + overscan?: number; +}) => { + const { count, getScrollElement, estimateSize, overscan = 5 } = options; + const [scrollTop, setScrollTop] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const refResizeObserver = useRef(null); + const refScrollElement = useRef(null); + const refRafId = useRef(null); + const itemHeight = estimateSize(); + + const updateContainer = useCallback((entries?: ResizeObserverEntry[]) => { + if (!refScrollElement.current) return; + + const height = + entries?.[0]?.contentRect.height ?? + refScrollElement.current.getBoundingClientRect().height; + setContainerHeight(height); + }, []); + + const debouncedUpdateContainer = useCallback(() => { + if (refRafId.current !== null) { + cancelAnimationFrame(refRafId.current); + } + refRafId.current = requestAnimationFrame(() => { + updateContainer(); + refRafId.current = null; + }); + }, [updateContainer]); + + useEffect(() => { + const element = getScrollElement(); + if (!element) return; + + refScrollElement.current = element; + + const handleScroll = () => { + if (!refScrollElement.current) return; + setScrollTop(refScrollElement.current.scrollTop); + }; + + updateContainer(); + + if (!refResizeObserver.current) { + refResizeObserver.current = new ResizeObserver(() => { + debouncedUpdateContainer(); + }); + } + refResizeObserver.current.observe(element); + + element.addEventListener('scroll', handleScroll, { passive: true }); + + const mutationObserver = new MutationObserver(debouncedUpdateContainer); + mutationObserver.observe(element, { + attributes: true, + childList: true, + subtree: true, + }); + + return () => { + element.removeEventListener('scroll', handleScroll); + if (refResizeObserver.current) { + refResizeObserver.current.disconnect(); + } + mutationObserver.disconnect(); + if (refRafId.current !== null) { + cancelAnimationFrame(refRafId.current); + } + }; + }, [getScrollElement, updateContainer, debouncedUpdateContainer]); + + const visibleRange = useMemo(() => { + const start = Math.floor(scrollTop / itemHeight); + const visibleCount = Math.ceil(containerHeight / itemHeight); + + return { + start: Math.max(0, start - overscan), + end: Math.min(count, start + visibleCount + overscan), + }; + }, [scrollTop, itemHeight, containerHeight, count, overscan]); + + const items = useMemo(() => { + const virtualItems: VirtualItem[] = []; + for (let index = visibleRange.start; index < visibleRange.end; index++) { + virtualItems.push({ + key: index, + index, + start: index * itemHeight, + }); + } + return virtualItems; + }, [visibleRange, itemHeight]); + + return { + virtualItems: items, + totalSize: count * itemHeight, + scrollTop, + containerHeight, + }; +}; diff --git a/packages/scan-react/src/web/state.ts b/packages/scan-react/src/web/state.ts new file mode 100644 index 00000000..a74142c3 --- /dev/null +++ b/packages/scan-react/src/web/state.ts @@ -0,0 +1,115 @@ +import { signal } from "~web/utils/signals"; +import { + LOCALSTORAGE_KEY, + LOCALSTORAGE_COLLAPSED_KEY, + MIN_CONTAINER_WIDTH, + MIN_SIZE, + SAFE_AREA, +} from "./constants"; +import { IS_CLIENT } from "./utils/constants"; +import { readLocalStorage, saveLocalStorage } from "./utils/helpers"; +import { getSafeArea } from "./utils/safe-area"; +import type { CollapsedPosition, Corner, WidgetConfig, WidgetSettings } from "./widget/types"; + +export const signalIsSettingsOpen = /* @__PURE__ */ signal(false); +export const signalRefWidget = /* @__PURE__ */ signal(null); + +// Use the raw SAFE_AREA constant (not getSafeArea()) here: this runs at +// module-init time, before any user has called scan() with options. +export const getDefaultWidgetConfig = (): WidgetConfig => ({ + corner: "bottom-right" satisfies Corner, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: MIN_SIZE.width, + height: MIN_SIZE.height, + position: { x: SAFE_AREA, y: SAFE_AREA }, + }, + lastDimensions: { + isFullWidth: false, + isFullHeight: false, + width: MIN_SIZE.width, + height: MIN_SIZE.height, + position: { x: SAFE_AREA, y: SAFE_AREA }, + }, + componentsTree: { + width: MIN_CONTAINER_WIDTH, + }, +}); + +// Deprecated alias kept for one minor version to avoid breaking downstream +// imports of the pre-refactor `defaultWidgetConfig` const. +/** @deprecated use {@link getDefaultWidgetConfig} */ +export const defaultWidgetConfig: WidgetConfig = getDefaultWidgetConfig(); + +const getInitialWidgetConfig = (): WidgetConfig => { + const defaults = getDefaultWidgetConfig(); + const stored = readLocalStorage(LOCALSTORAGE_KEY); + if (!stored) { + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: defaults.corner, + dimensions: defaults.dimensions, + lastDimensions: defaults.lastDimensions, + componentsTree: defaults.componentsTree, + }); + + return defaults; + } + + return { + corner: stored.corner ?? defaults.corner, + dimensions: stored.dimensions ?? defaults.dimensions, + + lastDimensions: stored.lastDimensions ?? stored.dimensions ?? defaults.lastDimensions, + componentsTree: stored.componentsTree ?? defaults.componentsTree, + }; +}; + +export const signalWidget = signal(getInitialWidgetConfig()); + +export const updateDimensions = (): void => { + if (!IS_CLIENT) return; + + const { dimensions } = signalWidget.value; + const { width, height, position } = dimensions; + const safeArea = getSafeArea(); + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: width >= window.innerWidth - safeArea.left - safeArea.right, + isFullHeight: height >= window.innerHeight - safeArea.top - safeArea.bottom, + width, + height, + position, + }, + }; +}; + +export type WidgetStates = + | { + view: "none"; + } + | { + view: "inspector"; + // extra params + } + // | { + // view: 'settings'; + // // extra params + // } + | { + view: "notifications"; + // extra params + }; +// | { +// view: 'summary'; +// // extra params +// }; +export const signalWidgetViews = signal({ + view: "none", +}); + +const storedCollapsed = readLocalStorage(LOCALSTORAGE_COLLAPSED_KEY); +export const signalWidgetCollapsed = + /* @__PURE__ */ signal(storedCollapsed ?? null); diff --git a/packages/scan-react/src/web/toolbar.tsx b/packages/scan-react/src/web/toolbar.tsx new file mode 100644 index 00000000..46fffbe6 --- /dev/null +++ b/packages/scan-react/src/web/toolbar.tsx @@ -0,0 +1,76 @@ +import { Component, type PropsWithChildren } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Icon } from './components/icon'; +import { Widget } from './widget'; +import { SvgSprite } from './components/svg-sprite'; + +class ToolbarErrorBoundary extends Component { + state: { hasError: boolean; error: Error | null } = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
+ + React Scan ran into a problem +
+
+ {this.state.error?.message || JSON.stringify(this.state.error)} +
+ +
+
+ ); + } + + return this.props.children; + } +} + +export const createToolbar = (root: ShadowRoot): HTMLElement => { + const container = document.createElement('div'); + container.id = 'react-scan-toolbar-root'; + window.__REACT_SCAN_TOOLBAR_CONTAINER__ = container; + root.appendChild(container); + + const reactRoot: Root = createRoot(container); + + reactRoot.render( + + <> + + + + , + ); + + const originalRemove = container.remove.bind(container); + + container.remove = () => { + window.__REACT_SCAN_TOOLBAR_CONTAINER__ = undefined; + + if (container.hasChildNodes()) { + reactRoot.unmount(); + } + + originalRemove(); + }; + + return container; +}; diff --git a/packages/scan-react/src/web/utils/check-react-grab-version.ts b/packages/scan-react/src/web/utils/check-react-grab-version.ts new file mode 100644 index 00000000..5819172e --- /dev/null +++ b/packages/scan-react/src/web/utils/check-react-grab-version.ts @@ -0,0 +1,45 @@ +import { version as REACT_GRAB_VERSION } from "react-grab/package.json"; + +declare global { + interface Window { + __REACT_GRAB__?: unknown; + } +} + +let didRunVersionCheck = false; + +export const checkReactGrabVersion = (): void => { + if (didRunVersionCheck) return; + didRunVersionCheck = true; + + if (typeof window === "undefined") return; + if (window.__REACT_GRAB__) return; + if (!navigator.onLine) return; + if (!REACT_GRAB_VERSION) return; + + const fetchOptions: RequestInit = { + referrerPolicy: "origin", + keepalive: true, + priority: "low", + cache: "no-store", + }; + + try { + fetch( + `https://www.react-grab.com/api/version?source=react-scan&v=${REACT_GRAB_VERSION}&t=${Date.now()}`, + fetchOptions, + ) + .then((response) => (response.ok ? response.text() : null)) + .then((rawLatestVersion) => { + if (!rawLatestVersion) return; + const latestVersion = rawLatestVersion.trim(); + if (!/^\d+\.\d+\.\d+/.test(latestVersion)) return; + if (latestVersion === REACT_GRAB_VERSION) return; + // oxlint-disable-next-line no-console + console.warn( + `[React Scan] react-grab v${REACT_GRAB_VERSION} is outdated (latest: v${latestVersion}). Update react-scan to pick up the newer react-grab.`, + ); + }) + .catch(() => null); + } catch {} +}; diff --git a/packages/scan-react/src/web/utils/constants.ts b/packages/scan-react/src/web/utils/constants.ts new file mode 100644 index 00000000..c8e4e4c8 --- /dev/null +++ b/packages/scan-react/src/web/utils/constants.ts @@ -0,0 +1 @@ +export const IS_CLIENT = typeof window !== 'undefined'; diff --git a/packages/scan-react/src/web/utils/copy-focused-element.ts b/packages/scan-react/src/web/utils/copy-focused-element.ts new file mode 100644 index 00000000..e2e769da --- /dev/null +++ b/packages/scan-react/src/web/utils/copy-focused-element.ts @@ -0,0 +1,13 @@ +import { getElementContext } from "react-grab/primitives"; + +export const copyFocusedElement = async (element: Element): Promise => { + try { + const context = await getElementContext(element); + const snippet = `${context.htmlPreview}${context.stackString}`; + if (!snippet.trim()) return false; + await navigator.clipboard.writeText(snippet); + return true; + } catch { + return false; + } +}; diff --git a/packages/scan-react/src/web/utils/create-store.ts b/packages/scan-react/src/web/utils/create-store.ts new file mode 100644 index 00000000..759f705d --- /dev/null +++ b/packages/scan-react/src/web/utils/create-store.ts @@ -0,0 +1,138 @@ +/** + * Adapted from zustand v5.0.3 + * + * https://github.com/pmndrs/zustand + * + * Do not modify unless you know what you are doing + */ +type SetStateInternal = { + _(partial: T | Partial | { _(state: T): T | Partial }["_"], replace?: false): void; + _(state: T | { _(state: T): T }["_"], replace: true): void; +}["_"]; + +export interface StoreApi { + setState: SetStateInternal; + getState: () => T; + getInitialState: () => T; + subscribe: { + (listener: (state: T, prevState: T) => void): () => void; + ( + selector: (state: T) => U, + listener: (selectedState: U, prevSelectedState: U) => void, + ): () => void; + }; +} + +type Get = K extends keyof T ? T[K] : F; + +export type Mutate = number extends Ms["length" & keyof Ms] + ? S + : Ms extends [] + ? S + : Ms extends [[infer Mi, infer Ma], ...infer Mrs] + ? Mutate[Mi & StoreMutatorIdentifier], Mrs> + : never; + +export type StateCreator< + T, + Mis extends [StoreMutatorIdentifier, unknown][] = [], + Mos extends [StoreMutatorIdentifier, unknown][] = [], + U = T, +> = (( + setState: Get, Mis>, "setState", never>, + getState: Get, Mis>, "getState", never>, + store: Mutate, Mis>, +) => U) & { $$storeMutators?: Mos }; + +// oxlint-disable-next-line no-unused-vars +export interface StoreMutators {} +export type StoreMutatorIdentifier = keyof StoreMutators; + +type CreateStore = { + ( + initializer: StateCreator, + ): Mutate, Mos>; + + (): ( + initializer: StateCreator, + ) => Mutate, Mos>; +}; + +type CreateStoreImpl = ( + initializer: StateCreator, +) => Mutate, Mos>; + +const createStoreImpl: CreateStoreImpl = (createState) => { + type TState = ReturnType; + type Listener = (state: TState, prevState: TState) => void; + let state: TState; + const listeners: Set = new Set(); + + const setState: StoreApi["setState"] = (partial, replace) => { + const nextState = + typeof partial === "function" ? (partial as (state: TState) => TState)(state) : partial; + if (!Object.is(nextState, state)) { + const previousState = state; + state = + (replace ?? (typeof nextState !== "object" || nextState === null)) + ? (nextState as TState) + : Object.assign({}, state, nextState); + listeners.forEach((listener) => listener(state, previousState)); + } + }; + + const getState: StoreApi["getState"] = () => state; + + const getInitialState: StoreApi["getInitialState"] = () => initialState; + + const subscribe: StoreApi["subscribe"] = ( + selectorOrListener: + | ((state: TState, prevState: TState) => void) + // oxlint-disable-next-line typescript/no-explicit-any + | ((state: TState) => any), + // oxlint-disable-next-line typescript/no-explicit-any + listener?: (selectedState: any, prevSelectedState: any) => void, + ) => { + // oxlint-disable-next-line typescript/no-explicit-any + let selector: ((state: TState) => any) | undefined; + // oxlint-disable-next-line typescript/no-explicit-any + let actualListener: (state: any, prevState: any) => void; + + if (listener) { + // Selector subscription case + // oxlint-disable-next-line typescript/no-explicit-any + selector = selectorOrListener as (state: TState) => any; + actualListener = listener; + } else { + // Regular subscription case + actualListener = selectorOrListener as (state: TState, prevState: TState) => void; + } + + let currentSlice = selector ? selector(state) : undefined; + + const wrappedListener = (newState: TState, previousState: TState) => { + if (selector) { + const nextSlice = selector(newState); + const prevSlice = selector(previousState); + if (!Object.is(currentSlice, nextSlice)) { + currentSlice = nextSlice; + actualListener(nextSlice, prevSlice); + } + } else { + actualListener(newState, previousState); + } + }; + + listeners.add(wrappedListener); + // Unsubscribe + return () => listeners.delete(wrappedListener); + }; + + const api = { setState, getState, getInitialState, subscribe }; + const initialState = (state = createState(setState, getState, api)); + // oxlint-disable-next-line typescript/no-explicit-any + return api as any; +}; + +export const createStore = ((createState) => + createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore; diff --git a/packages/scan-react/src/web/utils/has-non-empty-text-selection.ts b/packages/scan-react/src/web/utils/has-non-empty-text-selection.ts new file mode 100644 index 00000000..c5ef8b98 --- /dev/null +++ b/packages/scan-react/src/web/utils/has-non-empty-text-selection.ts @@ -0,0 +1,4 @@ +export const hasNonEmptyTextSelection = (): boolean => { + const selection = window.getSelection?.(); + return Boolean(selection && selection.toString().length > 0); +}; diff --git a/packages/scan-react/src/web/utils/helpers.ts b/packages/scan-react/src/web/utils/helpers.ts new file mode 100644 index 00000000..bc4e2a2a --- /dev/null +++ b/packages/scan-react/src/web/utils/helpers.ts @@ -0,0 +1,179 @@ +import { + type Fiber, + MemoComponentTag, + SimpleMemoComponentTag, + SuspenseComponentTag, + getDisplayName, + hasMemoCache, +} from "bippy"; +import { IS_CLIENT } from "./constants"; + +type ClassValue = string | number | bigint | null | boolean | undefined | ClassDictionary | ClassArray; +type ClassDictionary = Record; +type ClassArray = Array; + +const toClassString = (input: ClassValue): string => { + if (!input) return ""; + if (typeof input === "string" || typeof input === "number" || typeof input === "bigint") { + return String(input); + } + if (Array.isArray(input)) { + let result = ""; + for (const value of input) { + const part = toClassString(value); + if (part) { + result = result ? `${result} ${part}` : part; + } + } + return result; + } + if (typeof input !== "object") return ""; + const dict = input as ClassDictionary; + let result = ""; + for (const key in dict) { + if (dict[key]) { + result = result ? `${result} ${key}` : key; + } + } + return result; +}; + +export const cn = (...inputs: Array): string => { + const classNames: Array = []; + const seen = new Set(); + for (const part of toClassString(inputs).split(" ")) { + if (part && !seen.has(part)) { + seen.add(part); + classNames.push(part); + } + } + return classNames.join(" "); +}; + +export const throttle = (callback: (e?: E) => void, delay: number): ((e?: E) => void) => { + let lastCall = 0; + return (e?: E) => { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + return callback(e); + } + return undefined; + }; +}; + +export const readLocalStorage = (storageKey: string): T | null => { + if (!IS_CLIENT) return null; + + try { + const stored = localStorage.getItem(storageKey); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +export const saveLocalStorage = (storageKey: string, state: T): void => { + if (!IS_CLIENT) return; + + try { + window.localStorage.setItem(storageKey, JSON.stringify(state)); + } catch {} +}; +export const removeLocalStorage = (storageKey: string): void => { + if (!IS_CLIENT) return; + + try { + window.localStorage.removeItem(storageKey); + } catch {} +}; + +interface WrapperBadge { + type: "memo" | "forwardRef" | "lazy" | "suspense" | "profiler" | "strict"; + title: string; + compiler?: boolean; +} + +interface ExtendedDisplayName { + name: string | null; + wrappers: Array; + wrapperTypes: Array; +} + +// React internal tags not exported by bippy +const LazyComponentTag = 24; +const ProfilerTag = 12; + +export const getExtendedDisplayName = (fiber: Fiber): ExtendedDisplayName => { + if (!fiber) { + return { + name: "Unknown", + wrappers: [], + wrapperTypes: [], + }; + } + + const { tag, type, elementType } = fiber; + let name = getDisplayName(type); + const wrappers: Array = []; + const wrapperTypes: Array = []; + + if ( + hasMemoCache(fiber) || + tag === SimpleMemoComponentTag || + tag === MemoComponentTag || + (type as { $$typeof?: symbol })?.$$typeof === Symbol.for("react.memo") || + (elementType as { $$typeof?: symbol })?.$$typeof === Symbol.for("react.memo") + ) { + const compiler = hasMemoCache(fiber); + wrapperTypes.push({ + type: "memo", + title: compiler + ? "This component has been auto-memoized by the React Compiler." + : "Memoized component that skips re-renders if props are the same", + compiler, + }); + } + + if (tag === LazyComponentTag) { + wrapperTypes.push({ + type: "lazy", + title: "Lazily loaded component that supports code splitting", + }); + } + + if (tag === SuspenseComponentTag) { + wrapperTypes.push({ + type: "suspense", + title: "Component that can suspend while content is loading", + }); + } + + if (tag === ProfilerTag) { + wrapperTypes.push({ + type: "profiler", + title: "Component that measures rendering performance", + }); + } + + if (typeof name === "string") { + const wrapperRegex = /^(\w+)\((.*)\)$/; + let currentName = name; + while (wrapperRegex.test(currentName)) { + const match = currentName.match(wrapperRegex); + if (match?.[1] && match?.[2]) { + wrappers.unshift(match[1]); + currentName = match[2]; + } else { + break; + } + } + name = currentName; + } + + return { + name: name || "Unknown", + wrappers, + wrapperTypes, + }; +}; diff --git a/packages/scan-react/src/web/utils/is-finite-non-negative.ts b/packages/scan-react/src/web/utils/is-finite-non-negative.ts new file mode 100644 index 00000000..17fbd8f2 --- /dev/null +++ b/packages/scan-react/src/web/utils/is-finite-non-negative.ts @@ -0,0 +1,2 @@ +export const isFiniteNonNegative = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; diff --git a/packages/scan-react/src/web/utils/is-input-like-focused.ts b/packages/scan-react/src/web/utils/is-input-like-focused.ts new file mode 100644 index 00000000..43bec327 --- /dev/null +++ b/packages/scan-react/src/web/utils/is-input-like-focused.ts @@ -0,0 +1,8 @@ +export const isInputLikeFocused = (): boolean => { + const active = document.activeElement; + if (!active) return false; + const tag = active.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if (active instanceof HTMLElement && active.isContentEditable) return true; + return false; +}; diff --git a/packages/scan-react/src/web/utils/is-mac.ts b/packages/scan-react/src/web/utils/is-mac.ts new file mode 100644 index 00000000..288fbdde --- /dev/null +++ b/packages/scan-react/src/web/utils/is-mac.ts @@ -0,0 +1,6 @@ +export const isMac = (): boolean => { + if (typeof navigator === "undefined") return false; + const platform = navigator.platform || ""; + if (platform) return /Mac|iPhone|iPad|iPod/i.test(platform); + return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent); +}; diff --git a/packages/scan-react/src/web/utils/is-plain-object.ts b/packages/scan-react/src/web/utils/is-plain-object.ts new file mode 100644 index 00000000..2558f466 --- /dev/null +++ b/packages/scan-react/src/web/utils/is-plain-object.ts @@ -0,0 +1,4 @@ +export const isPlainObject = ( + value: unknown, +): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); diff --git a/packages/scan-react/src/web/utils/is-user-react-grab-active.ts b/packages/scan-react/src/web/utils/is-user-react-grab-active.ts new file mode 100644 index 00000000..4fce462d --- /dev/null +++ b/packages/scan-react/src/web/utils/is-user-react-grab-active.ts @@ -0,0 +1,8 @@ +declare global { + interface Window { + __REACT_GRAB__?: unknown; + } +} + +export const isUserReactGrabActive = (): boolean => + typeof window !== "undefined" && Boolean(window.__REACT_GRAB__); diff --git a/packages/scan-react/src/web/utils/log.ts b/packages/scan-react/src/web/utils/log.ts new file mode 100644 index 00000000..5ce0e27b --- /dev/null +++ b/packages/scan-react/src/web/utils/log.ts @@ -0,0 +1,91 @@ +// @ts-nocheck +import { ChangeReason, type Render } from "~core/instrumentation"; +import { getLabelText } from "~core/utils"; + +export const log = (renders: Array) => { + const logMap = new Map< + string, + Array<{ prev: unknown; next: unknown; type: string; unstable?: boolean }> + >(); + for (let i = 0, len = renders.length; i < len; i++) { + const render = renders[i]; + + if (!render.componentName) continue; + + const changeLog = logMap.get(render.componentName) ?? []; + const labelText = getLabelText([ + { + aggregatedCount: 1, + + computedKey: null, + name: render.componentName, + frame: null, + ...render, + changes: { + // TODO(Alexis): use a faster reduction method + type: render.changes.reduce((set, change) => set | change.type, 0), + unstable: render.changes.some((change) => change.unstable), + }, + phase: render.phase, + computedCurrent: null, + }, + ]); + if (!labelText) continue; + + let prevChangedProps: Record | null = null; + let nextChangedProps: Record | null = null; + + if (render.changes) { + for (let i = 0, len = render.changes.length; i < len; i++) { + const { name, prevValue, nextValue, unstable, type } = render.changes[i]; + if (type === ChangeReason.Props) { + prevChangedProps ??= {}; + nextChangedProps ??= {}; + prevChangedProps[`${unstable ? "⚠️" : ""}${name} (prev)`] = prevValue; + nextChangedProps[`${unstable ? "⚠️" : ""}${name} (next)`] = nextValue; + } else { + changeLog.push({ + prev: prevValue, + next: nextValue, + type: type === ChangeReason.Context ? "context" : "state", + unstable: unstable ?? false, + }); + } + } + } + + if (prevChangedProps && nextChangedProps) { + changeLog.push({ + prev: prevChangedProps, + next: nextChangedProps, + type: "props", + unstable: false, + }); + } + + logMap.set(labelText, changeLog); + } + for (const [name, changeLog] of Array.from(logMap.entries())) { + // 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) { + // oxlint-disable-next-line no-console + console.log(`${type}:`, unstable ? "⚠️" : "", prev, "!==", next); + } + // oxlint-disable-next-line no-console + console.groupEnd(); + } +}; + +export const logIntro = () => { + if (window.hideIntro) { + window.hideIntro = undefined; + return; + } + // 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;", + ); +}; diff --git a/packages/scan-react/src/web/utils/parse-safe-area-option.test.ts b/packages/scan-react/src/web/utils/parse-safe-area-option.test.ts new file mode 100644 index 00000000..0fa1d9e4 --- /dev/null +++ b/packages/scan-react/src/web/utils/parse-safe-area-option.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseSafeAreaOption } from '~web/utils/parse-safe-area-option'; + +describe('parseSafeAreaOption', () => { + it('accepts a non-negative finite number', () => { + expect(parseSafeAreaOption(0)).toEqual({ ok: true, value: 0 }); + expect(parseSafeAreaOption(24)).toEqual({ ok: true, value: 24 }); + expect(parseSafeAreaOption(96)).toEqual({ ok: true, value: 96 }); + }); + + it('rejects negative numbers', () => { + const result = parseSafeAreaOption(-1); + expect(result.ok).toBe(false); + }); + + it('rejects NaN and Infinity', () => { + expect(parseSafeAreaOption(Number.NaN).ok).toBe(false); + expect(parseSafeAreaOption(Number.POSITIVE_INFINITY).ok).toBe(false); + expect(parseSafeAreaOption(Number.NEGATIVE_INFINITY).ok).toBe(false); + }); + + it('accepts a partial per-edge object', () => { + expect(parseSafeAreaOption({ right: 96 })).toEqual({ + ok: true, + value: { right: 96 }, + }); + expect(parseSafeAreaOption({ top: 0, bottom: 40 })).toEqual({ + ok: true, + value: { top: 0, bottom: 40 }, + }); + }); + + it('accepts a full per-edge object', () => { + expect( + parseSafeAreaOption({ top: 1, right: 2, bottom: 3, left: 4 }), + ).toEqual({ + ok: true, + value: { top: 1, right: 2, bottom: 3, left: 4 }, + }); + }); + + it('rejects per-edge object with a negative value', () => { + const result = parseSafeAreaOption({ right: -10 }); + expect(result.ok).toBe(false); + }); + + it('rejects per-edge object with a non-number value', () => { + const result = parseSafeAreaOption({ top: '24' }); + expect(result.ok).toBe(false); + }); + + it('rejects arrays (not plain objects)', () => { + const result = parseSafeAreaOption([10, 20, 30, 40]); + expect(result.ok).toBe(false); + }); + + it('rejects null and undefined', () => { + expect(parseSafeAreaOption(null).ok).toBe(false); + expect(parseSafeAreaOption(undefined).ok).toBe(false); + }); + + it('rejects strings', () => { + expect(parseSafeAreaOption('24').ok).toBe(false); + expect(parseSafeAreaOption('').ok).toBe(false); + }); + + it('ignores undefined edges in a per-edge object', () => { + expect( + parseSafeAreaOption({ top: 5, right: undefined }), + ).toEqual({ ok: true, value: { top: 5 } }); + }); +}); diff --git a/packages/scan-react/src/web/utils/parse-safe-area-option.ts b/packages/scan-react/src/web/utils/parse-safe-area-option.ts new file mode 100644 index 00000000..7c70810f --- /dev/null +++ b/packages/scan-react/src/web/utils/parse-safe-area-option.ts @@ -0,0 +1,38 @@ +import type { Options } from '~core/index'; +import { isFiniteNonNegative } from '~web/utils/is-finite-non-negative'; +import { isPlainObject } from '~web/utils/is-plain-object'; + +type SafeAreaOption = NonNullable; + +export type ParsedSafeAreaOption = + | { ok: true; value: SafeAreaOption } + | { ok: false; error: string }; + +const SAFE_AREA_EDGES = ['top', 'right', 'bottom', 'left'] as const; + +export const parseSafeAreaOption = (value: unknown): ParsedSafeAreaOption => { + if (isFiniteNonNegative(value)) { + return { ok: true, value }; + } + + if (!isPlainObject(value)) { + return { + ok: false, + error: `- safeArea must be a non-negative number or { top?, right?, bottom?, left? }. Got "${JSON.stringify(value)}"`, + }; + } + + const inset: Partial> = {}; + for (const edge of SAFE_AREA_EDGES) { + const edgeValue = value[edge]; + if (edgeValue === undefined) continue; + if (!isFiniteNonNegative(edgeValue)) { + return { + ok: false, + error: `- safeArea.${edge} must be a non-negative number. Got "${JSON.stringify(edgeValue)}"`, + }; + } + inset[edge] = edgeValue; + } + return { ok: true, value: inset }; +}; diff --git a/packages/scan-react/src/web/utils/pin.ts b/packages/scan-react/src/web/utils/pin.ts new file mode 100644 index 00000000..8e10d77f --- /dev/null +++ b/packages/scan-react/src/web/utils/pin.ts @@ -0,0 +1,24 @@ +import type { Fiber } from 'bippy'; + +export const getFiberPath = (fiber: Fiber): string => { + const pathSegments: string[] = []; + let currentFiber: Fiber | null = fiber; + + while (currentFiber) { + const elementType = currentFiber.elementType; + const name = + typeof elementType === 'function' + ? elementType.displayName || elementType.name + : typeof elementType === 'string' + ? elementType + : 'Unknown'; + + const index = + currentFiber.index !== undefined ? `[${currentFiber.index}]` : ''; + pathSegments.unshift(`${name}${index}`); + + currentFiber = currentFiber.return ?? null; + } + + return pathSegments.join('::'); +}; diff --git a/packages/scan-react/src/web/utils/preact/constant.ts b/packages/scan-react/src/web/utils/preact/constant.ts new file mode 100644 index 00000000..8ee577fc --- /dev/null +++ b/packages/scan-react/src/web/utils/preact/constant.ts @@ -0,0 +1,11 @@ +import { type FunctionComponent, memo } from "react"; + +function CONSTANT_UPDATE() { + return true; +} + +export function constant

(Component: FunctionComponent

) { + const Memoed = memo(Component, CONSTANT_UPDATE); + Memoed.displayName = `Memo(${Component.displayName || Component.name})`; + return Memoed; +} diff --git a/packages/scan-react/src/web/utils/safe-area.ts b/packages/scan-react/src/web/utils/safe-area.ts new file mode 100644 index 00000000..7a33ae9f --- /dev/null +++ b/packages/scan-react/src/web/utils/safe-area.ts @@ -0,0 +1,39 @@ +import { ReactScanInternals } from '~core/index'; +import { SAFE_AREA } from '~web/constants'; +import { isFiniteNonNegative } from '~web/utils/is-finite-non-negative'; +import { isPlainObject } from '~web/utils/is-plain-object'; + +export interface SafeAreaInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export const getSafeArea = (): SafeAreaInsets => { + const value = ReactScanInternals.options.value.safeArea; + + if (isFiniteNonNegative(value)) { + return { top: value, right: value, bottom: value, left: value }; + } + + if (isPlainObject(value)) { + const top = value.top; + const right = value.right; + const bottom = value.bottom; + const left = value.left; + return { + top: isFiniteNonNegative(top) ? top : SAFE_AREA, + right: isFiniteNonNegative(right) ? right : SAFE_AREA, + bottom: isFiniteNonNegative(bottom) ? bottom : SAFE_AREA, + left: isFiniteNonNegative(left) ? left : SAFE_AREA, + }; + } + + return { + top: SAFE_AREA, + right: SAFE_AREA, + bottom: SAFE_AREA, + left: SAFE_AREA, + }; +}; diff --git a/packages/scan-react/src/web/utils/signals.ts b/packages/scan-react/src/web/utils/signals.ts new file mode 100644 index 00000000..55a68359 --- /dev/null +++ b/packages/scan-react/src/web/utils/signals.ts @@ -0,0 +1,237 @@ +/** + * React-idiomatic replacement for `@preact/signals`. + * + * The original Preact UI relied on signals' transparent auto-subscription: + * reading `someSignal.value` inside a component body re-rendered that + * component when the signal changed. React has no such mechanism, so this + * module provides: + * + * 1. A tiny reactive core (`signal` / `computed`) that is API-compatible + * with the `.value` / `.peek()` / `.subscribe()` surface used by the + * non-component code (effects, event handlers, and the shared `~core` + * engine). These keep working unchanged. + * + * 2. React hooks (`useSignalValue`, `useComputed`, `useSignal`, + * `useSignalEffect`) that bridge those stores into React's render cycle + * via `useSyncExternalStore` / `useEffect`. + * + * PORTING RULE: anywhere a component USED to read `mySignal.value` in its + * render body, read it through `useSignalValue(mySignal)` (or `useComputed`) + * at the top of the component instead. Reads/writes OUTSIDE render (handlers, + * effects, module scope) stay as plain `.value` / `.subscribe`. + */ +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; + +type Listener = () => void; + +/** A tracking scope: a `computed` recompute or a `useSignalEffect` run. */ +class Reaction { + deps = new Set>(); + + constructor(public readonly run: () => void) {} + + /** Drop all current dependency edges (called before each re-track). */ + clearDeps(): void { + for (const dep of this.deps) dep.reactions.delete(this); + this.deps.clear(); + } + + dispose(): void { + this.clearDeps(); + } +} + +let activeReaction: Reaction | null = null; + +/** Run `fn` while recording every signal/computed it reads as a dependency. */ +function track(reaction: Reaction, fn: () => void): void { + reaction.clearDeps(); + const prev = activeReaction; + activeReaction = reaction; + try { + fn(); + } finally { + activeReaction = prev; + } +} + +abstract class ReactiveNode { + /** Plain subscribers registered via `.subscribe()`. */ + protected subs = new Set(); + /** Tracking reactions (computeds / effects) that depend on this node. */ + reactions = new Set(); + protected current!: T; + + protected abstract read(): T; + + get value(): T { + const v = this.read(); + if (activeReaction) { + activeReaction.deps.add(this as ReactiveNode); + this.reactions.add(activeReaction); + } + return v; + } + + /** Read without subscribing the active reaction. */ + peek(): T { + return this.read(); + } + + /** + * Subscribe to changes. Matches `@preact/signals` semantics: the callback + * fires immediately with the current value, then on every change. + */ + subscribe(fn: (value: T) => void): () => void { + const wrapped: Listener = () => fn(this.read()); + this.subs.add(wrapped); + fn(this.read()); + return () => { + this.subs.delete(wrapped); + }; + } + + protected notify(): void { + for (const sub of [...this.subs]) sub(); + for (const reaction of [...this.reactions]) reaction.run(); + } +} + +class Signal extends ReactiveNode { + constructor(initial: T) { + super(); + this.current = initial; + } + + protected read(): T { + return this.current; + } + + override get value(): T { + return super.value; + } + + set value(next: T) { + if (Object.is(next, this.current)) return; + this.current = next; + this.notify(); + } +} + +class Computed extends ReactiveNode { + private reaction: Reaction; + private dirty = true; + + constructor(private readonly compute: () => T) { + super(); + this.reaction = new Reaction(() => this.recompute()); + } + + private recompute(): void { + let next!: T; + track(this.reaction, () => { + next = this.compute(); + }); + const changed = this.dirty || !Object.is(next, this.current); + this.current = next; + this.dirty = false; + if (changed) this.notify(); + } + + protected read(): T { + if (this.dirty) this.recompute(); + return this.current; + } +} + +export type { Signal, Computed }; +/** Read-only view used by code that only consumes a signal/computed. */ +export type ReadonlySignal = Pick, "value" | "peek" | "subscribe">; + +export function signal(initial: T): Signal { + return new Signal(initial); +} + +export function computed(compute: () => T): Computed { + return new Computed(compute); +} + +/** Imperative effect that re-runs whenever a tracked signal changes. */ +export function effect(fn: () => void | (() => void)): () => void { + let cleanup: void | (() => void); + const reaction = new Reaction(() => { + if (typeof cleanup === "function") cleanup(); + track(reaction, () => { + cleanup = fn(); + }); + }); + track(reaction, () => { + cleanup = fn(); + }); + return () => { + if (typeof cleanup === "function") cleanup(); + reaction.dispose(); + }; +} + +/** Read a signal/computed inside a component, subscribing to its changes. */ +export function useSignalValue(source: ReadonlySignal): T { + const subscribe = useCallback( + (onChange: () => void) => { + // `.subscribe` invokes immediately; React ignores that initial call. + let first = true; + return source.subscribe(() => { + if (first) { + first = false; + return; + } + onChange(); + }); + }, + [source], + ); + const getSnapshot = useCallback(() => source.peek(), [source]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Derive a memoized value from signals inside a component, subscribing to the + * signals it reads. For purely module-level derivations prefer a top-level + * `computed(...)` + `useSignalValue`; use this for component-scoped derivations. + */ +export function useComputed(compute: () => T): T { + const ref = useRef | null>(null); + if (ref.current === null) ref.current = computed(compute); + return useSignalValue(ref.current); +} + +/** + * Local component signal. Prefer plain `useState` for trivial local state; + * this exists for cases ported 1:1 from `useSignal`, where `.value` + * reads/writes are threaded through helpers. Writing `.value` re-renders. + */ +export function useSignal(initial: T): Signal { + const ref = useRef | null>(null); + if (ref.current === null) ref.current = signal(initial); + // Subscribe so `.value` mutations trigger a re-render of this component. + useSignalValue(ref.current); + return ref.current; +} + +/** Run an effect that re-subscribes to whichever signals it reads. */ +export function useSignalEffect(fn: () => void | (() => void)): void { + const fnRef = useRef(fn); + fnRef.current = fn; + useEffect(() => effect(() => fnRef.current()), []); +} + +/** `untracked(fn)` reads signals inside `fn` without creating dependencies. */ +export function untracked(fn: () => T): T { + const prev = activeReaction; + activeReaction = null; + try { + return fn(); + } finally { + activeReaction = prev; + } +} diff --git a/packages/scan-react/src/web/views/index.tsx b/packages/scan-react/src/web/views/index.tsx new file mode 100644 index 00000000..d238ecb7 --- /dev/null +++ b/packages/scan-react/src/web/views/index.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react'; +import { type ReadonlySignal, computed, useSignalValue } from '~web/utils/signals'; +import { Store } from '~core/index'; +import { signalWidgetViews } from '~web/state'; +import { cn } from '~web/utils/helpers'; +import { Header } from '~web/widget/header'; +import { ViewInspector } from './inspector'; +import { Toolbar } from './toolbar'; +import { NotificationWrapper } from './notifications/notifications'; + +const isInspecting = computed( + () => Store.inspectState.value.kind === 'inspecting', +); + +const headerClassName = computed(() => + cn( + 'relative', + 'flex-1', + 'flex flex-col', + 'rounded-t-lg', + 'overflow-hidden', + 'opacity-100', + 'transition-[opacity]', + isInspecting.value && 'opacity-0 duration-0 delay-0', + ), +); + +const isInspectorViewOpen = computed( + () => signalWidgetViews.value.view === 'inspector', +); +const isNotificationsViewOpen = computed( + () => signalWidgetViews.value.view === 'notifications', +); + +export const Content = () => { + const headerClassNameValue = useSignalValue(headerClassName); + return ( +

+
+
+
+ + + + + + + +
+
+ +
+ ); +}; + +interface ContentViewProps { + isOpen: ReadonlySignal; + children: ReactNode; +} + +const ContentView = ({ isOpen, children }: ContentViewProps) => { + const isOpenValue = useSignalValue(isOpen); + return ( +
+
{children}
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/inspector/components-tree/index.tsx b/packages/scan-react/src/web/views/inspector/components-tree/index.tsx new file mode 100644 index 00000000..eae37aa3 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/components-tree/index.tsx @@ -0,0 +1,1179 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Store } from '~core/index'; +import { getRenderData } from '~core/instrumentation'; +import { Icon } from '~web/components/icon'; +import { + LOCALSTORAGE_KEY, + MIN_CONTAINER_WIDTH, +} from '~web/constants'; +import { useVirtualList } from '~web/hooks/use-virtual-list'; +import { signalWidget } from '~web/state'; +import { + cn, + getExtendedDisplayName, + saveLocalStorage, +} from '~web/utils/helpers'; +import { getFiberPath } from '~web/utils/pin'; +import { useSignalValue } from '~web/utils/signals'; +import { inspectorUpdateSignal } from '../states'; +import { + type InspectableElement, + getCompositeComponentFromElement, + getInspectableElements, +} from '../utils'; +import { + type FlattenedNode, + type TreeNode, + searchState, + signalSkipTreeUpdate, +} from './state'; + +const flattenTree = ( + nodes: TreeNode[], + depth = 0, + parentPath: string | null = null, +): FlattenedNode[] => { + return nodes.reduce((acc, node, index) => { + const nodePath = node.element + ? getFiberPath(node.fiber) + : `${parentPath}-${index}`; + + const renderData = node.fiber?.type + ? getRenderData(node.fiber as Parameters[0]) + : undefined; + + const flatNode: FlattenedNode = { + ...node, + depth, + nodeId: nodePath, + parentId: parentPath, + fiber: node.fiber, + renderData, + }; + acc.push(flatNode); + + if (node.children?.length) { + acc.push(...flattenTree(node.children, depth + 1, nodePath)); + } + + return acc; + }, []); +}; + +const getMaxDepth = (nodes: FlattenedNode[]): number => { + return nodes.reduce((max, node) => Math.max(max, node.depth), 0); +}; + +const calculateIndentSize = (containerWidth: number, maxDepth: number) => { + const MIN_INDENT = 0; + const MAX_INDENT = 24; + const MIN_TOTAL_INDENT = 24; + + if (maxDepth <= 0) return MAX_INDENT; + + const availableSpace = Math.max(0, containerWidth - MIN_CONTAINER_WIDTH); + + if (availableSpace < MIN_TOTAL_INDENT) return MIN_INDENT; + + const targetTotalIndent = Math.min( + availableSpace * 0.3, + maxDepth * MAX_INDENT, + ); + const baseIndent = targetTotalIndent / maxDepth; + + return Math.max(MIN_INDENT, Math.min(MAX_INDENT, baseIndent)); +}; + +interface TreeNodeItemProps { + node: FlattenedNode; + nodeIndex: number; + hasChildren: boolean; + isCollapsed: boolean; + handleTreeNodeClick: (e: React.MouseEvent) => void; + handleTreeNodeToggle: (e: React.MouseEvent) => void; + searchValue: typeof searchState.value; +} + +const VALID_TYPES = ['memo', 'forwardRef', 'lazy', 'suspense']; + +const parseTypeSearch = (query: string) => { + const typeMatch = query.match(/\[(.*?)\]/); + if (!typeMatch) return null; + + const typeSearches: string[] = []; + const parts = typeMatch[1].split(','); + for (const part of parts) { + const trimmed = part.trim().toLowerCase(); + if (trimmed) typeSearches.push(trimmed); + } + + return typeSearches; +}; + +const isValidTypeSearch = (typeSearches: string[]) => { + if (typeSearches.length === 0) return false; + + for (const search of typeSearches) { + let isValid = false; + for (const validType of VALID_TYPES) { + if (validType.toLowerCase().includes(search)) { + isValid = true; + break; + } + } + if (!isValid) return false; + } + return true; +}; + +const matchesTypeSearch = ( + typeSearches: string[], + wrapperTypes: Array<{ type: string }>, +) => { + if (typeSearches.length === 0) return true; + if (!wrapperTypes.length) return false; + + for (const search of typeSearches) { + let foundMatch = false; + for (const wrapper of wrapperTypes) { + if (wrapper.type.toLowerCase().includes(search)) { + foundMatch = true; + break; + } + } + if (!foundMatch) return false; + } + return true; +}; + +const useNodeHighlighting = ( + node: FlattenedNode, + searchValue: typeof searchState.value, +) => { + return useMemo(() => { + const { query, matches } = searchValue; + const isMatch = matches.some((match) => match.nodeId === node.nodeId); + const typeSearches = parseTypeSearch(query) || []; + const searchQuery = query ? query.replace(/\[.*?\]/, '').trim() : ''; + + if (!query || !isMatch) { + return { + highlightedText: {node.label}, + typeHighlight: false, + }; + } + + let matchesType = true; + if (typeSearches.length > 0) { + if (!node.fiber) { + matchesType = false; + } else { + const { wrapperTypes } = getExtendedDisplayName(node.fiber); + matchesType = matchesTypeSearch(typeSearches, wrapperTypes); + } + } + + let textContent = {node.label}; + if (searchQuery) { + try { + if (searchQuery.startsWith('/') && searchQuery.endsWith('/')) { + const pattern = searchQuery.slice(1, -1); + const regex = new RegExp(`(${pattern})`, 'i'); + const parts = node.label.split(regex); + + textContent = ( + + {parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ), + )} + + ); + } else { + const lowerLabel = node.label.toLowerCase(); + const lowerQuery = searchQuery.toLowerCase(); + const index = lowerLabel.indexOf(lowerQuery); + + if (index >= 0) { + textContent = ( + + {node.label.slice(0, index)} + + {node.label.slice(index, index + searchQuery.length)} + + {node.label.slice(index + searchQuery.length)} + + ); + } + } + } catch {} + } + + return { + highlightedText: textContent, + typeHighlight: matchesType && typeSearches.length > 0, + }; + }, [node.label, node.nodeId, node.fiber, searchValue]); +}; + +const formatTime = (time: number) => { + if (time > 0) { + if (time < 0.1 - Number.EPSILON) { + return '< 0.1'; + } + if (time < 1000) { + return Number(time.toFixed(1)).toString(); + } + return `${(time / 1000).toFixed(1)}k`; + } + return '0'; +}; + +const TreeNodeItem = ({ + node, + nodeIndex, + hasChildren, + isCollapsed, + handleTreeNodeClick, + handleTreeNodeToggle, + searchValue, +}: TreeNodeItemProps) => { + const refRenderCount = useRef(null); + const refPrevRenderCount = useRef(node.renderData?.renderCount ?? 0); + + const { highlightedText, typeHighlight } = useNodeHighlighting( + node, + searchValue, + ); + + useEffect(() => { + const currentRenderCount = node.renderData?.renderCount; + const element = refRenderCount.current; + if ( + !element || + !refPrevRenderCount.current || + !currentRenderCount || + refPrevRenderCount.current === currentRenderCount + ) { + return; + } + + element.classList.remove('count-flash'); + void element.offsetWidth; + element.classList.add('count-flash'); + + refPrevRenderCount.current = currentRenderCount; + }, [node.renderData?.renderCount]); + + const renderTimeInfo = useMemo(() => { + if (!node.renderData) return null; + const { selfTime, totalTime, renderCount } = node.renderData; + + if (!renderCount) { + return null; + } + + return ( + + + ×{renderCount} + + + ); + }, [node.renderData]); + + const componentTypes = useMemo(() => { + if (!node.fiber) return null; + const { wrapperTypes } = getExtendedDisplayName(node.fiber); + const firstWrapperType = wrapperTypes[0]; + + return ( + + {firstWrapperType && ( + <> + + {firstWrapperType.type} + + {firstWrapperType.compiler && ( + + )} + + )} + {wrapperTypes.length > 1 && `×${wrapperTypes.length}`} + {renderTimeInfo} + + ); + }, [node.fiber, typeHighlight, renderTimeInfo]); + + return ( + + {highlightedText} + {componentTypes} + + ); +}; + +export const ComponentsTree = () => { + const refContainer = useRef(null); + const refMainContainer = useRef(null); + const refSearchInputContainer = useRef(null); + const refSearchInput = useRef(null); + const refSelectedElement = useRef(null); + const refMaxTreeDepth = useRef(0); + const refIsHovering = useRef(false); + const refIsResizing = useRef(false); + const refResizeHandle = useRef(null); + + const [flattenedNodes, setFlattenedNodes] = useState([]); + const [collapsedNodes, setCollapsedNodes] = useState>(new Set()); + const [selectedIndex, setSelectedIndex] = useState( + undefined, + ); + const [searchValue, setSearchValue] = useState(searchState.value); + const searchStateValue = useSignalValue(searchState); + + const visibleNodes = useMemo(() => { + const visible: FlattenedNode[] = []; + const nodes = flattenedNodes; + const nodeMap = new Map(nodes.map((node) => [node.nodeId, node])); + + for (const node of nodes) { + let isVisible = true; + + let currentNode = node; + while (currentNode.parentId) { + const parent = nodeMap.get(currentNode.parentId); + if (!parent) break; + + if (collapsedNodes.has(parent.nodeId)) { + isVisible = false; + break; + } + currentNode = parent; + } + + if (isVisible) { + visible.push(node); + } + } + + return visible; + }, [collapsedNodes, flattenedNodes]); + + const ITEM_HEIGHT = 28; + + const { virtualItems, totalSize } = useVirtualList({ + count: visibleNodes.length, + getScrollElement: () => refContainer.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + }); + + const handleElementClick = useCallback( + (element: HTMLElement) => { + refIsHovering.current = true; + refSearchInput.current?.blur(); + signalSkipTreeUpdate.value = true; + + const { parentCompositeFiber } = + getCompositeComponentFromElement(element); + if (!parentCompositeFiber) return; + + Store.inspectState.value = { + kind: 'focused', + focusedDomElement: element, + fiber: parentCompositeFiber, + }; + + const nodeIndex = visibleNodes.findIndex( + (node) => node.element === element, + ); + if (nodeIndex !== -1) { + setSelectedIndex(nodeIndex); + const itemTop = nodeIndex * ITEM_HEIGHT; + const container = refContainer.current; + if (container) { + const containerHeight = container.clientHeight; + const scrollTop = container.scrollTop; + + if ( + itemTop < scrollTop || + itemTop + ITEM_HEIGHT > scrollTop + containerHeight + ) { + container.scrollTo({ + top: Math.max(0, itemTop - containerHeight / 2), + behavior: 'instant', + }); + } + } + } + }, + [visibleNodes], + ); + + const handleTreeNodeClick = useCallback( + (e: React.MouseEvent) => { + const target = e.currentTarget as HTMLElement; + const index = Number(target.dataset.index); + if (Number.isNaN(index)) return; + const element = visibleNodes[index].element; + if (!element) return; + handleElementClick(element); + }, + [visibleNodes, handleElementClick], + ); + + const handleToggle = useCallback((nodeId: string) => { + setCollapsedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + const handleTreeNodeToggle = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const target = e.target as HTMLElement; + const index = Number(target.dataset.index); + if (Number.isNaN(index)) return; + const nodeId = visibleNodes[index].nodeId; + handleToggle(nodeId); + }, + [visibleNodes, handleToggle], + ); + + const handleOnChangeSearch = useCallback( + (query: string) => { + refSearchInputContainer.current?.classList.remove('!border-red-500'); + const matches: FlattenedNode[] = []; + + if (!query) { + searchState.value = { query, matches, currentMatchIndex: -1 }; + return; + } + + if (query.includes('[') && !query.includes(']')) { + if (query.length > query.indexOf('[') + 1) { + refSearchInputContainer.current?.classList.add('!border-red-500'); + return; + } + } + + const typeSearches = parseTypeSearch(query) || []; + if (query.includes('[')) { + if (!isValidTypeSearch(typeSearches)) { + refSearchInputContainer.current?.classList.add('!border-red-500'); + return; + } + } + + const searchQuery = query.replace(/\[.*?\]/, '').trim(); + const isRegex = /^\/.*\/$/.test(searchQuery); + let matchesLabel = (_label: string) => false; + + if (searchQuery.startsWith('/') && !isRegex) { + if (searchQuery.length > 1) { + refSearchInputContainer.current?.classList.add('!border-red-500'); + return; + } + } + + if (isRegex) { + try { + const pattern = searchQuery.slice(1, -1); + const regex = new RegExp(pattern, 'i'); + matchesLabel = (label: string) => regex.test(label); + } catch { + refSearchInputContainer.current?.classList.add('!border-red-500'); + return; + } + } else if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + matchesLabel = (label: string) => + label.toLowerCase().includes(lowerQuery); + } + + for (const node of flattenedNodes) { + let matchesSearch = true; + + if (searchQuery) { + matchesSearch = matchesLabel(node.label); + } + + if (matchesSearch && typeSearches.length > 0) { + if (!node.fiber) { + matchesSearch = false; + } else { + const { wrapperTypes } = getExtendedDisplayName(node.fiber); + matchesSearch = matchesTypeSearch(typeSearches, wrapperTypes); + } + } + + if (matchesSearch) { + matches.push(node); + } + } + + searchState.value = { + query, + matches, + currentMatchIndex: matches.length > 0 ? 0 : -1, + }; + + if (matches.length > 0) { + const firstMatch = matches[0]; + const nodeIndex = visibleNodes.findIndex( + (node) => node.nodeId === firstMatch.nodeId, + ); + if (nodeIndex !== -1) { + const itemTop = nodeIndex * ITEM_HEIGHT; + const container = refContainer.current; + if (container) { + const containerHeight = container.clientHeight; + container.scrollTo({ + top: Math.max(0, itemTop - containerHeight / 2), + behavior: 'instant', + }); + } + } + } + }, + [flattenedNodes, visibleNodes], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const target = e.currentTarget as HTMLInputElement; + if (!target) return; + handleOnChangeSearch(target.value); + }, + [handleOnChangeSearch], + ); + + const navigateSearch = useCallback( + (direction: 'next' | 'prev') => { + const { matches, currentMatchIndex } = searchState.value; + if (matches.length === 0) return; + + const newIndex = + direction === 'next' + ? (currentMatchIndex + 1) % matches.length + : (currentMatchIndex - 1 + matches.length) % matches.length; + + searchState.value = { + ...searchState.value, + currentMatchIndex: newIndex, + }; + + const currentMatch = matches[newIndex]; + const nodeIndex = visibleNodes.findIndex( + (node) => node.nodeId === currentMatch.nodeId, + ); + if (nodeIndex !== -1) { + setSelectedIndex(nodeIndex); + const itemTop = nodeIndex * ITEM_HEIGHT; + const container = refContainer.current; + if (container) { + const containerHeight = container.clientHeight; + container.scrollTo({ + top: Math.max(0, itemTop - containerHeight / 2), + behavior: 'instant', + }); + } + } + }, + [visibleNodes], + ); + + const updateContainerWidths = useCallback((width: number) => { + if (refMainContainer.current) { + refMainContainer.current.style.width = `${width}px`; + } + if (refContainer.current) { + refContainer.current.style.width = `${width}px`; + const indentSize = calculateIndentSize(width, refMaxTreeDepth.current); + refContainer.current.style.setProperty( + '--indentation-size', + `${indentSize}px`, + ); + } + }, []); + + const updateResizeDirection = useCallback((width: number) => { + if (!refResizeHandle.current) return; + + const parentWidth = signalWidget.value.dimensions.width; + const maxWidth = Math.floor(parentWidth - (MIN_CONTAINER_WIDTH / 2)); + + refResizeHandle.current.classList.remove( + 'cursor-ew-resize', + 'cursor-w-resize', + 'cursor-e-resize', + ); + + if (width <= MIN_CONTAINER_WIDTH) { + refResizeHandle.current.classList.add('cursor-w-resize'); + } else if (width >= maxWidth) { + refResizeHandle.current.classList.add('cursor-e-resize'); + } else { + refResizeHandle.current.classList.add('cursor-ew-resize'); + } + }, []); + + const handleResize = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!refContainer.current) return; + refContainer.current.style.setProperty('pointer-events', 'none'); + + refIsResizing.current = true; + + const startX = e.clientX; + const startWidth = refContainer.current.offsetWidth; + const parentWidth = signalWidget.value.dimensions.width; + const maxWidth = Math.floor(parentWidth - (MIN_CONTAINER_WIDTH / 2)); + + updateResizeDirection(startWidth); + + const handlePointerMove = (e: PointerEvent) => { + const delta = startX - e.clientX; + const newWidth = startWidth + delta; + updateResizeDirection(newWidth); + + const clampedWidth = Math.min( + maxWidth, + Math.max(MIN_CONTAINER_WIDTH, newWidth), + ); + updateContainerWidths(clampedWidth); + }; + + const handlePointerUp = () => { + if (!refContainer.current) return; + refContainer.current.style.removeProperty('pointer-events'); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + + signalWidget.value = { + ...signalWidget.value, + componentsTree: { + ...signalWidget.value.componentsTree, + width: refContainer.current.offsetWidth, + }, + }; + + saveLocalStorage(LOCALSTORAGE_KEY, signalWidget.value); + refIsResizing.current = false; + }; + + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + }, + [updateContainerWidths, updateResizeDirection], + ); + + useEffect(() => { + if (!refContainer.current) return; + const currentWidth = refContainer.current.offsetWidth; + updateResizeDirection(currentWidth); + + return signalWidget.subscribe(() => { + if (!refContainer.current) return; + updateResizeDirection(refContainer.current.offsetWidth); + }); + }, [updateResizeDirection]); + + const onPointerLeave = useCallback(() => { + refIsHovering.current = false; + }, []); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + let isInitialTreeBuild = true; + const buildTreeFromElements = (elements: Array) => { + const nodeMap = new Map(); + const rootNodes: TreeNode[] = []; + + for (const { element, name, fiber } of elements) { + if (!element) continue; + + let title = name; + const { name: componentName, wrappers } = getExtendedDisplayName(fiber); + if (componentName) { + if (wrappers.length > 0) { + title = `${wrappers.join('(')}(${componentName})${')'.repeat(wrappers.length)}`; + } else { + title = componentName; + } + } + + nodeMap.set(element, { + label: componentName || name, + title, + children: [], + element, + fiber, + }); + } + + for (const { element, depth } of elements) { + if (!element) continue; + const node = nodeMap.get(element); + if (!node) continue; + + if (depth === 0) { + rootNodes.push(node); + } else { + let parent = element.parentElement; + while (parent) { + const parentNode = nodeMap.get(parent); + if (parentNode) { + parentNode.children = parentNode.children || []; + parentNode.children.push(node); + break; + } + parent = parent.parentElement; + } + } + } + + return rootNodes; + }; + + const updateTree = () => { + const element = refSelectedElement.current; + if (!element) return; + + const inspectableElements = getInspectableElements(); + const tree = buildTreeFromElements(inspectableElements); + + if (tree.length > 0) { + const flattened = flattenTree(tree); + const newMaxDepth = getMaxDepth(flattened); + refMaxTreeDepth.current = newMaxDepth; + + updateContainerWidths(signalWidget.value.componentsTree.width); + setFlattenedNodes(flattened); + + if (isInitialTreeBuild) { + isInitialTreeBuild = false; + const focusedIndex = flattened.findIndex( + (node) => node.element === element, + ); + if (focusedIndex !== -1) { + const itemTop = focusedIndex * ITEM_HEIGHT; + const container = refContainer.current; + if (container) { + setTimeout(() => { + container.scrollTo({ + top: itemTop, + behavior: 'instant', + }); + }, 96); + } + } + } + } + }; + + const unsubscribeStore = Store.inspectState.subscribe((state) => { + if (state.kind === 'focused') { + if (signalSkipTreeUpdate.value) { + return; + } + + handleOnChangeSearch(''); + refSelectedElement.current = state.focusedDomElement as HTMLElement; + updateTree(); + } + }); + + let rafId = 0; + const unsubscribeUpdates = inspectorUpdateSignal.subscribe(() => { + if (Store.inspectState.value.kind === 'focused') { + cancelAnimationFrame(rafId); + if (refIsResizing.current) return; + + rafId = requestAnimationFrame(() => { + signalSkipTreeUpdate.value = false; + updateTree(); + }); + } + }); + + return () => { + unsubscribeStore(); + unsubscribeUpdates(); + + searchState.value = { + query: '', + matches: [], + currentMatchIndex: -1, + }; + }; + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!refIsHovering.current) return; + + if (!selectedIndex) return; + + switch (e.key) { + case 'ArrowUp': { + e.preventDefault(); + e.stopPropagation(); + + if (selectedIndex > 0) { + const currentNode = visibleNodes[selectedIndex - 1]; + if (currentNode?.element) { + handleElementClick(currentNode.element); + } + } + return; + } + case 'ArrowDown': { + e.preventDefault(); + e.stopPropagation(); + + if (selectedIndex < visibleNodes.length - 1) { + const currentNode = visibleNodes[selectedIndex + 1]; + if (currentNode?.element) { + handleElementClick(currentNode.element); + } + } + return; + } + case 'ArrowLeft': { + e.preventDefault(); + e.stopPropagation(); + + const currentNode = visibleNodes[selectedIndex]; + if (currentNode?.nodeId) { + handleToggle(currentNode.nodeId); + } + return; + } + case 'ArrowRight': { + e.preventDefault(); + e.stopPropagation(); + + const currentNode = visibleNodes[selectedIndex]; + if (currentNode?.nodeId) { + handleToggle(currentNode.nodeId); + } + return; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [selectedIndex, visibleNodes, handleElementClick, handleToggle]); + + useEffect(() => { + return searchState.subscribe(setSearchValue); + }, []); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const unsubscribe = signalWidget.subscribe((state) => { + refMainContainer.current?.style.setProperty('transition', 'width 0.1s'); + updateContainerWidths(state.componentsTree.width); + + setTimeout(() => { + refMainContainer.current?.style.removeProperty('transition'); + }, 500); + }); + return unsubscribe; + }, []); + + return ( +
+
+ + + +
+
+
+
+ +
+ { + e.stopPropagation(); + e.currentTarget.focus(); + }} + onPointerDown={(e) => { + e.stopPropagation(); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur(); + } + if (searchState.value.matches.length) { + if (e.key === 'Enter' && e.shiftKey) { + navigateSearch('prev'); + } else if (e.key === 'Enter') { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + handleElementClick( + searchState.value.matches[ + searchState.value.currentMatchIndex + ].element as HTMLElement, + ); + + e.currentTarget.focus(); + } else { + navigateSearch('next'); + } + } + } + }} + onChange={handleInputChange} + className="absolute inset-y-0 inset-x-1" + placeholder="Component name, /regex/, or [type]" + /> +
+ {searchStateValue.query ? ( + <> + + {searchStateValue.currentMatchIndex + 1} + {'|'} + {searchStateValue.matches.length} + + {!!searchStateValue.matches.length && ( + <> + + + + )} + + + ) : ( + !!flattenedNodes.length && ( + + {flattenedNodes.length} + + ) + )} +
+
+
+
+
+ {virtualItems.map((virtualItem) => { + const node = visibleNodes[virtualItem.index]; + if (!node) return null; + + const isSelected = + Store.inspectState.value.kind === 'focused' && + node.element === Store.inspectState.value.focusedDomElement; + const isKeyboardSelected = virtualItem.index === selectedIndex; + + return ( +
+
+ +
+
+ ); + })} +
+
+
+
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/inspector/components-tree/state.ts b/packages/scan-react/src/web/views/inspector/components-tree/state.ts new file mode 100644 index 00000000..3b99783b --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/components-tree/state.ts @@ -0,0 +1,31 @@ +import { signal } from "~web/utils/signals"; +import type { Fiber } from "bippy"; +import type { RenderData } from "~core/instrumentation"; + +export interface TreeNode { + label: string; + title?: string; + fiber: Fiber; + element?: HTMLElement; + children?: TreeNode[]; + renderData?: RenderData; +} + +export interface FlattenedNode extends TreeNode { + depth: number; + nodeId: string; + parentId: string | null; + fiber: Fiber; +} + +export const searchState = signal<{ + query: string; + matches: FlattenedNode[]; + currentMatchIndex: number; +}>({ + query: "", + matches: [], + currentMatchIndex: -1, +}); + +export const signalSkipTreeUpdate = /* @__PURE__ */ signal(false); diff --git a/packages/scan-react/src/web/views/inspector/diff-value.tsx b/packages/scan-react/src/web/views/inspector/diff-value.tsx new file mode 100644 index 00000000..4b79096f --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/diff-value.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { CopyToClipboard } from '~web/components/copy-to-clipboard'; +import { Icon } from '~web/components/icon'; +import { cn } from '~web/utils/helpers'; +import { formatForClipboard, formatValuePreview, safeGetValue } from './utils'; + +const ArrayHeader = ({ + length, + expanded, + onToggle, + isNegative, +}: { + length: number; + expanded: boolean; + onToggle: () => void; + isNegative: boolean; +}) => ( +
+ + Array({length}) +
+); + +const TreeNode = ({ + value, + path, + isNegative, +}: { + value: unknown; + path: string; + isNegative: boolean; +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const canExpand = value !== null && + typeof value === 'object' && + !(value instanceof Date); + + if (!canExpand) { + return ( +
+ {path}: + {formatValuePreview(value)} +
+ ); + } + + const entries = Object.entries(value as object); + + return ( +
+
+ + {path}: + {!isExpanded && ( + + {value instanceof Date ? formatValuePreview(value) : `{${Object.keys(value).join(', ')}}`} + + )} +
+ {isExpanded && ( +
+ {entries.map(([key, val]) => ( + + ))} +
+ )} +
+ ); +}; + +export const DiffValueView = ({ + value, + expanded, + onToggle, + isNegative, +}: { + value: unknown; + expanded: boolean; + onToggle: () => void; + isNegative: boolean; +}) => { + const { value: safeValue, error } = safeGetValue(value); + + if (error) { + return {error}; + } + + const isExpandable = + safeValue !== null && + typeof safeValue === 'object' && + !(safeValue instanceof Promise); + + if (!isExpandable) { + return {formatValuePreview(safeValue)}; + } + + if (Array.isArray(safeValue)) { + return ( +
+ + {expanded && ( +
+ {safeValue.map((item, index) => ( + + ))} +
+ )} + + {({ ClipboardIcon }) => <>{ClipboardIcon}} + +
+ ); + } + + // Handle objects + return ( +
+ +
+ {!expanded ? ( + {formatValuePreview(safeValue)} + ) : ( +
+ {Object.entries(safeValue as object).map(([key, val]) => ( + + ))} +
+ )} +
+ + {({ ClipboardIcon }) => <>{ClipboardIcon}} + +
+ ); +}; diff --git a/packages/scan-react/src/web/views/inspector/flash-overlay.ts b/packages/scan-react/src/web/views/inspector/flash-overlay.ts new file mode 100644 index 00000000..2164e2c6 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/flash-overlay.ts @@ -0,0 +1,112 @@ +interface FlashEntry { + element: HTMLElement; + overlay: HTMLElement; + scrollCleanup?: () => void; +} + +const fadeOutTimers = new WeakMap>(); + +const trackElementPosition = ( + element: Element, + callback: (element: Element) => void, +): (() => void) => { + const handleScroll = callback.bind(null, element); + + document.addEventListener('scroll', handleScroll, { + passive: true, + capture: true, + }); + + return () => { + document.removeEventListener('scroll', handleScroll, { capture: true }); + }; +}; + +export const flashManager = { + activeFlashes: new Map(), + + create(container: HTMLElement) { + const existingOverlay = container.querySelector( + '.react-scan-flash-overlay', + ); + + const overlay = + existingOverlay instanceof HTMLElement + ? existingOverlay + : (() => { + const newOverlay = document.createElement('div'); + newOverlay.className = 'react-scan-flash-overlay'; + container.appendChild(newOverlay); + + const scrollCleanup = trackElementPosition(container, () => { + if (container.querySelector('.react-scan-flash-overlay')) { + this.create(container); + } + }); + + this.activeFlashes.set(container, { + element: container, + overlay: newOverlay, + scrollCleanup, + }); + + return newOverlay; + })(); + + const existingTimer = fadeOutTimers.get(overlay); + if (existingTimer) { + clearTimeout(existingTimer); + fadeOutTimers.delete(overlay); + } + + requestAnimationFrame(() => { + overlay.style.transition = 'none'; + overlay.style.opacity = '0.9'; + + const timerId = setTimeout(() => { + overlay.style.transition = 'opacity 150ms ease-out'; + overlay.style.opacity = '0'; + + const cleanupTimer = setTimeout(() => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + const entry = this.activeFlashes.get(container); + if (entry?.scrollCleanup) { + entry.scrollCleanup(); + } + this.activeFlashes.delete(container); + fadeOutTimers.delete(overlay); + }, 150); + + fadeOutTimers.set(overlay, cleanupTimer); + }, 300); + + fadeOutTimers.set(overlay, timerId); + }); + }, + + cleanup(container: HTMLElement) { + const entry = this.activeFlashes.get(container); + if (entry) { + const existingTimer = fadeOutTimers.get(entry.overlay); + if (existingTimer) { + clearTimeout(existingTimer); + fadeOutTimers.delete(entry.overlay); + } + if (entry.overlay.parentNode) { + entry.overlay.parentNode.removeChild(entry.overlay); + } + if (entry.scrollCleanup) { + entry.scrollCleanup(); + } + this.activeFlashes.delete(container); + } + }, + + cleanupAll() { + for (const [, entry] of this.activeFlashes) { + this.cleanup(entry.element); + } + }, +}; diff --git a/packages/scan-react/src/web/views/inspector/header.tsx b/packages/scan-react/src/web/views/inspector/header.tsx new file mode 100644 index 00000000..737f3d76 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/header.tsx @@ -0,0 +1,133 @@ +import type { Fiber } from 'bippy'; +import { useMemo, useRef, useState } from 'react'; +import { Store } from '~core/index'; +import { signalIsSettingsOpen } from '~web/state'; +import { + computed, + untracked, + useSignalEffect, + useSignalValue, +} from '~web/utils/signals'; +import { cn, getExtendedDisplayName } from '~web/utils/helpers'; +import { timelineState } from './states'; + +const headerInspectClassName = computed(() => + cn( + 'absolute inset-0 flex items-center gap-x-2', + 'translate-y-0', + 'transition-transform duration-300', + signalIsSettingsOpen.value && '-translate-y-[200%]', + ), +); + +export const HeaderInspect = () => { + const refReRenders = useRef(null); + const refTiming = useRef(null); + const [currentFiber, setCurrentFiber] = useState(null); + const className = useSignalValue(headerInspectClassName); + + useSignalEffect(() => { + const state = Store.inspectState.value; + + if (state.kind === 'focused') { + setCurrentFiber(state.fiber); + } + }); + + useSignalEffect(() => { + const state = timelineState.value; + untracked(() => { + if (Store.inspectState.value.kind !== 'focused') return; + if (!refReRenders.current || !refTiming.current) return; + + const { totalUpdates, currentIndex, updates, isVisible, windowOffset } = + state; + + const reRenders = Math.max(0, totalUpdates - 1); + const headerText = isVisible + ? `#${windowOffset + currentIndex} Re-render` + : reRenders > 0 + ? `×${reRenders}` + : ''; + + let formattedTime: string | undefined; + if (reRenders > 0 && currentIndex >= 0 && currentIndex < updates.length) { + const time = updates[currentIndex]?.fiberInfo?.selfTime; + formattedTime = + time > 0 + ? time < 0.1 - Number.EPSILON + ? '< 0.1ms' + : `${Number(time.toFixed(1))}ms` + : undefined; + } + + // TODO(Alexis): can be computed signal + refReRenders.current.dataset.text = headerText ? ` • ${headerText}` : ''; + refTiming.current.dataset.text = formattedTime + ? ` • ${formattedTime}` + : ''; + }); + }); + + const componentName = useMemo(() => { + if (!currentFiber) return null; + const { name, wrappers, wrapperTypes } = + getExtendedDisplayName(currentFiber); + + const title = wrappers.length + ? `${wrappers.join('(')}(${name})${')'.repeat(wrappers.length)}` + : (name ?? ''); + + const firstWrapperType = wrapperTypes[0]; + return ( + + {name ?? 'Unknown'} + + {!!firstWrapperType && ( + <> + + {firstWrapperType.type} + + {firstWrapperType.compiler && ( + + )} + + )} + + {wrapperTypes.length > 1 && ( + + ×{wrapperTypes.length - 1} + + )} + + ); + }, [currentFiber]); + + return ( +
+ {componentName} + {/* useless info */} +
+ + +
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/inspector/index.tsx b/packages/scan-react/src/web/views/inspector/index.tsx new file mode 100644 index 00000000..76eeccd8 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/index.tsx @@ -0,0 +1,224 @@ +import type { Fiber } from 'bippy'; +import { Component, type PropsWithChildren, useEffect, useRef } from 'react'; +import { Store } from '~core/index'; +import { Icon } from '~web/components/icon'; +import { signalIsSettingsOpen, signalWidgetViews } from '~web/state'; +import { cn } from '~web/utils/helpers'; +import { constant } from '~web/utils/preact/constant'; +import { + computed, + untracked, + useSignalEffect, + useSignalValue, +} from '~web/utils/signals'; +import { ComponentsTree } from './components-tree'; +import { flashManager } from './flash-overlay'; +import { + type TimelineUpdate, + inspectorUpdateSignal, + timelineActions, +} from './states'; +import { + collectInspectorData, + getStateNames, + resetTracking, +} from './timeline/utils'; +import { extractMinimalFiberInfo, getCompositeFiberFromElement } from './utils'; +import { WhatChanged } from './what-changed'; + +export const globalInspectorState = { + lastRendered: new Map(), + expandedPaths: new Set(), + cleanup: () => { + globalInspectorState.lastRendered.clear(); + globalInspectorState.expandedPaths.clear(); + flashManager.cleanupAll(); + resetTracking(); + timelineActions.reset(); + }, +}; + +// todo: add reset button and error message +class InspectorErrorBoundary extends Component { + state: { error: Error | null; hasError: boolean } = { + hasError: false, + error: null, + }; + + static getDerivedStateFromError(e: Error) { + return { hasError: true, error: e }; + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + globalInspectorState.cleanup(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ + Something went wrong in the inspector +
+
+ {this.state.error?.message || JSON.stringify(this.state.error)} +
+ +
+ ); + } + + return this.props.children; + } +} + +const inspectorContainerClassName = computed(() => + cn( + 'react-scan-inspector', + 'flex-1', + 'opacity-0', + 'overflow-y-auto overflow-x-hidden', + 'transition-opacity delay-0', + 'pointer-events-none', + !signalIsSettingsOpen.value && 'opacity-100 delay-300 pointer-events-auto', + ), +); + +const Inspector = /* @__PURE__ */ constant(() => { + const refLastInspectedFiber = useRef(null); + const containerClassName = useSignalValue(inspectorContainerClassName); + + // NOTE(Alexis): no need for useCallback + const processUpdate = (fiber: Fiber) => { + if (!fiber) return; + + refLastInspectedFiber.current = fiber; + const { data: inspectorData, shouldUpdate } = collectInspectorData(fiber); + + if (shouldUpdate) { + const update: TimelineUpdate = { + timestamp: Date.now(), + fiberInfo: extractMinimalFiberInfo(fiber), + props: inspectorData.fiberProps, + state: inspectorData.fiberState, + context: inspectorData.fiberContext, + stateNames: getStateNames(fiber), + }; + + timelineActions.addUpdate(update, fiber); + } + }; + + useSignalEffect(() => { + const state = Store.inspectState.value; + untracked(() => { + if (state.kind !== 'focused' || !state.focusedDomElement) { + refLastInspectedFiber.current = null; + globalInspectorState.cleanup(); + return; + } + + if (state.kind === 'focused') { + signalIsSettingsOpen.value = false; + } + + const { parentCompositeFiber } = getCompositeFiberFromElement( + state.focusedDomElement, + state.fiber, + ); + + if (!parentCompositeFiber) { + Store.inspectState.value = { + kind: 'inspect-off', + }; + signalWidgetViews.value = { + view: 'none', + }; + return; + } + + const isNewComponent = + refLastInspectedFiber.current?.type !== parentCompositeFiber.type; + + if (isNewComponent) { + refLastInspectedFiber.current = parentCompositeFiber; + globalInspectorState.cleanup(); + processUpdate(parentCompositeFiber); + } + }); + }); + + useSignalEffect(() => { + // NOTE(Alexis): just track + inspectorUpdateSignal.value; + untracked(() => { + const inspectState = Store.inspectState.value; + if (inspectState.kind !== 'focused' || !inspectState.focusedDomElement) { + refLastInspectedFiber.current = null; + globalInspectorState.cleanup(); + return; + } + + const { parentCompositeFiber } = getCompositeFiberFromElement( + inspectState.focusedDomElement, + inspectState.fiber, + ); + + if (!parentCompositeFiber) { + Store.inspectState.value = { + kind: 'inspect-off', + }; + signalWidgetViews.value = { + view: 'none', + }; + return; + } + + processUpdate(parentCompositeFiber); + + if (!inspectState.focusedDomElement.isConnected) { + refLastInspectedFiber.current = null; + globalInspectorState.cleanup(); + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: null, + }; + } + }); + }); + + useEffect(() => { + return () => { + globalInspectorState.cleanup(); + }; + }, []); + + return ( + +
+
+ +
+
+
+ ); +}); + +export const ViewInspector = /* @__PURE__ */ constant(() => { + const inspectState = useSignalValue(Store.inspectState); + if (inspectState.kind !== 'focused') return null; + return ( + + + + + ); +}); diff --git a/packages/scan-react/src/web/views/inspector/overlay/index.tsx b/packages/scan-react/src/web/views/inspector/overlay/index.tsx new file mode 100644 index 00000000..29100c7d --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/overlay/index.tsx @@ -0,0 +1,677 @@ +import { type Fiber, getDisplayName } from "bippy"; +import { useEffect, useRef } from "react"; +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"; +const lerp = (start: number, end: number, t: number) => start + (end - start) * t; +import { + type States, + findComponentDOMNode, + getAssociatedFiberRect, + getCompositeComponentFromElement, + nonVisualTags, +} from "../utils"; + +type DrawKind = "locked" | "inspecting"; + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +interface LockIconRect { + x: number; + y: number; + width: number; + height: number; +} + +const ANIMATION_CONFIG = { + frameInterval: 1000 / 60, + speeds: { + fast: 0.51, + slow: 0.1, + off: 0, + }, +} as const; + +const OVERLAY_DPR = IS_CLIENT ? /* @__PURE__ */ window.devicePixelRatio || 1 : 1; + +export const ScanOverlay = () => { + const refCanvas = useRef(null); + const refEventCatcher = useRef(null); + const refCurrentRect = useRef(null); + const refCurrentLockIconRect = useRef(null); + const refLastHoveredElement = useRef(null); + const refRafId = useRef(0); + const refTimeout = useRef | undefined>(undefined); + const refCleanupMap = useRef(new Map void>()); + const refIsFadingOut = useRef(false); + const refLastFrameTime = useRef(0); + + const drawLockIcon = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { + ctx.save(); + ctx.strokeStyle = "white"; + ctx.fillStyle = "white"; + ctx.lineWidth = 1.5; + + const shackleWidth = size * 0.6; + const shackleHeight = size * 0.5; + const shackleX = x + (size - shackleWidth) / 2; + const shackleY = y; + + ctx.beginPath(); + ctx.arc( + shackleX + shackleWidth / 2, + shackleY + shackleHeight / 2, + shackleWidth / 2, + Math.PI, + 0, + false, + ); + ctx.stroke(); + + const bodyWidth = size * 0.8; + const bodyHeight = size * 0.5; + const bodyX = x + (size - bodyWidth) / 2; + const bodyY = y + shackleHeight / 2; + + ctx.fillRect(bodyX, bodyY, bodyWidth, bodyHeight); + ctx.restore(); + }; + + const drawStatsPill = ( + ctx: CanvasRenderingContext2D, + rect: Rect, + kind: "locked" | "inspecting", + fiber: Fiber | null, + ) => { + if (!fiber) return; + + const pillHeight = 24; + const pillPadding = 8; + const componentName = (fiber?.type && getDisplayName(fiber.type)) ?? "Unknown"; + const text = componentName; + + ctx.save(); + ctx.font = "12px system-ui, -apple-system, sans-serif"; + const textMetrics = ctx.measureText(text); + const textWidth = textMetrics.width; + const lockIconSize = kind === "locked" ? 14 : 0; + const lockIconPadding = kind === "locked" ? 6 : 0; + const pillWidth = textWidth + pillPadding * 2 + lockIconSize + lockIconPadding; + + const pillX = rect.left; + const pillY = rect.top - pillHeight - 4; + + ctx.fillStyle = "rgb(37, 37, 38, .75)"; + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 3); + ctx.fill(); + + if (kind === "locked") { + const lockX = pillX + pillPadding; + const lockY = pillY + (pillHeight - lockIconSize) / 2 + 2; + drawLockIcon(ctx, lockX, lockY, lockIconSize); + refCurrentLockIconRect.current = { + x: lockX, + y: lockY, + width: lockIconSize, + height: lockIconSize, + }; + } else { + refCurrentLockIconRect.current = null; + } + + ctx.fillStyle = "white"; + ctx.textBaseline = "middle"; + const textX = pillX + pillPadding + (kind === "locked" ? lockIconSize + lockIconPadding : 0); + ctx.fillText(text, textX, pillY + pillHeight / 2); + ctx.restore(); + }; + + const drawRect = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + kind: DrawKind, + fiber: Fiber | null, + ) => { + if (!refCurrentRect.current) return; + const rect = refCurrentRect.current; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = "rgba(142, 97, 227, 0.5)"; + ctx.fillStyle = "rgba(173, 97, 230, 0.10)"; + + if (kind === "locked") { + ctx.setLineDash([]); + } else { + ctx.setLineDash([4]); + } + + ctx.lineWidth = 1; + ctx.fillRect(rect.left, rect.top, rect.width, rect.height); + ctx.strokeRect(rect.left, rect.top, rect.width, rect.height); + + drawStatsPill(ctx, rect, kind, fiber); + }; + + const animate = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + targetRect: Rect, + kind: DrawKind, + parentCompositeFiber: Fiber, + onComplete?: () => void, + ) => { + const speed = ReactScanInternals.options.value + .animationSpeed as keyof typeof ANIMATION_CONFIG.speeds; + const t = ANIMATION_CONFIG.speeds[speed] ?? ANIMATION_CONFIG.speeds.off; + + const animationFrame = (timestamp: number) => { + if (timestamp - refLastFrameTime.current < ANIMATION_CONFIG.frameInterval) { + refRafId.current = requestAnimationFrame(animationFrame); + return; + } + refLastFrameTime.current = timestamp; + + if (!refCurrentRect.current) { + cancelAnimationFrame(refRafId.current); + return; + } + + refCurrentRect.current = { + left: lerp(refCurrentRect.current.left, targetRect.left, t), + top: lerp(refCurrentRect.current.top, targetRect.top, t), + width: lerp(refCurrentRect.current.width, targetRect.width, t), + height: lerp(refCurrentRect.current.height, targetRect.height, t), + }; + + drawRect(canvas, ctx, kind, parentCompositeFiber); + + const stillMoving = + Math.abs(refCurrentRect.current.left - targetRect.left) > 0.1 || + Math.abs(refCurrentRect.current.top - targetRect.top) > 0.1 || + Math.abs(refCurrentRect.current.width - targetRect.width) > 0.1 || + Math.abs(refCurrentRect.current.height - targetRect.height) > 0.1; + + if (stillMoving) { + refRafId.current = requestAnimationFrame(animationFrame); + } else { + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + cancelAnimationFrame(refRafId.current); + ctx.restore(); + onComplete?.(); + } + }; + + cancelAnimationFrame(refRafId.current); + clearTimeout(refTimeout.current); + + refRafId.current = requestAnimationFrame(animationFrame); + + refTimeout.current = setTimeout(() => { + cancelAnimationFrame(refRafId.current); + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + ctx.restore(); + onComplete?.(); + }, 1000); + }; + + const setupOverlayAnimation = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + targetRect: Rect, + kind: DrawKind, + parentCompositeFiber: Fiber, + ) => { + ctx.save(); + + if (!refCurrentRect.current) { + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + ctx.restore(); + return; + } + + animate(canvas, ctx, targetRect, kind, parentCompositeFiber); + }; + + const drawHoverOverlay = async ( + overlayElement: Element | null, + canvas: HTMLCanvasElement | null, + ctx: CanvasRenderingContext2D | null, + kind: DrawKind, + ) => { + if (!overlayElement || !canvas || !ctx) return; + + const { parentCompositeFiber } = getCompositeComponentFromElement(overlayElement); + const targetRect = await getAssociatedFiberRect(overlayElement); + + if (!parentCompositeFiber || !targetRect) return; + + setupOverlayAnimation(canvas, ctx, targetRect, kind, parentCompositeFiber); + }; + + const unsubscribeAll = () => { + for (const cleanup of refCleanupMap.current.values()) { + cleanup?.(); + } + }; + + const cleanupCanvas = (canvas: HTMLCanvasElement) => { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + refCurrentRect.current = null; + refCurrentLockIconRect.current = null; + refLastHoveredElement.current = null; + canvas.classList.remove("fade-in"); + refIsFadingOut.current = false; + }; + + const startFadeOut = (onComplete?: () => void) => { + if (!refCanvas.current || refIsFadingOut.current) return; + + const handleTransitionEnd = (e: TransitionEvent) => { + if (!refCanvas.current || e.propertyName !== "opacity" || !refIsFadingOut.current) { + return; + } + refCanvas.current.removeEventListener("transitionend", handleTransitionEnd); + cleanupCanvas(refCanvas.current); + onComplete?.(); + }; + const existingListener = refCleanupMap.current.get("fade-out"); + if (existingListener) { + existingListener(); + refCleanupMap.current.delete("fade-out"); + } + + refCanvas.current.addEventListener("transitionend", handleTransitionEnd); + refCleanupMap.current.set("fade-out", () => { + refCanvas.current?.removeEventListener("transitionend", handleTransitionEnd); + }); + + refIsFadingOut.current = true; + refCanvas.current.classList.remove("fade-in"); + requestAnimationFrame(() => { + refCanvas.current?.classList.add("fade-out"); + }); + }; + + const startFadeIn = () => { + if (!refCanvas.current) return; + refIsFadingOut.current = false; + refCanvas.current.classList.remove("fade-out"); + requestAnimationFrame(() => { + refCanvas.current?.classList.add("fade-in"); + }); + }; + + const handleHoverableElement = (componentElement: Element) => { + if (componentElement === refLastHoveredElement.current) return; + + refLastHoveredElement.current = componentElement; + + if (nonVisualTags.has(componentElement.tagName)) { + startFadeOut(); + } else { + startFadeIn(); + } + + Store.inspectState.value = { + kind: "inspecting", + hoveredDomElement: componentElement, + }; + }; + + const handleNonHoverableArea = () => { + if (!refCurrentRect.current || !refCanvas.current || refIsFadingOut.current) { + return; + } + + startFadeOut(); + }; + + const handlePointerMove = throttle((e?: PointerEvent) => { + const state = Store.inspectState.peek(); + if (state.kind !== "inspecting" || !refEventCatcher.current) return; + + refEventCatcher.current.style.pointerEvents = "none"; + const element = document.elementFromPoint(e?.clientX ?? 0, e?.clientY ?? 0); + + refEventCatcher.current.style.removeProperty("pointer-events"); + + clearTimeout(refTimeout.current); + + if (element && element !== refCanvas.current) { + const { parentCompositeFiber } = getCompositeComponentFromElement(element as Element); + if (parentCompositeFiber) { + const componentElement = findComponentDOMNode(parentCompositeFiber); + if (componentElement) { + handleHoverableElement(componentElement); + return; + } + } + } + + handleNonHoverableArea(); + }, 32); + + const isClickInLockIcon = (e: MouseEvent, canvas: HTMLCanvasElement) => { + const currentRect = refCurrentLockIconRect.current; + if (!currentRect) return false; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + const adjustedX = x / OVERLAY_DPR; + const adjustedY = y / OVERLAY_DPR; + + return ( + adjustedX >= currentRect.x && + adjustedX <= currentRect.x + currentRect.width && + adjustedY >= currentRect.y && + adjustedY <= currentRect.y + currentRect.height + ); + }; + + const handleLockIconClick = (state: States) => { + if (state.kind === "focused") { + Store.inspectState.value = { + kind: "inspecting", + hoveredDomElement: state.focusedDomElement, + }; + } + }; + + const handleElementClick = (e: MouseEvent) => { + const clickableElements = ["react-scan-inspect-element", "react-scan-power"]; + // avoid capturing the synthetic event sent back to the toolbar, we don't want to block click events on it ever + if (e.target instanceof HTMLElement && clickableElements.includes(e.target.id)) { + return; + } + + const tagName = refLastHoveredElement.current?.tagName; + if (tagName && nonVisualTags.has(tagName)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const element = + refLastHoveredElement.current ?? document.elementFromPoint(e.clientX, e.clientY); + if (!element) return; + + const clickedEl = e.composedPath().at(0); + + if (clickedEl instanceof HTMLElement && clickableElements.includes(clickedEl.id)) { + const syntheticEvent = new MouseEvent(e.type, e); + // @ts-ignore - this allows to know to not re-process this event when this event handler captures it + syntheticEvent.__reactScanSyntheticEvent = true; + clickedEl.dispatchEvent(syntheticEvent); + return; + } + const { parentCompositeFiber } = getCompositeComponentFromElement(element as Element); + if (!parentCompositeFiber) return; + + const componentElement = findComponentDOMNode(parentCompositeFiber); + + if (!componentElement) { + refLastHoveredElement.current = null; + Store.inspectState.value = { + kind: "inspect-off", + }; + return; + } + + Store.inspectState.value = { + kind: "focused", + focusedDomElement: componentElement, + fiber: parentCompositeFiber, + }; + }; + + const handleClick = (e: MouseEvent) => { + // @ts-ignore - metadata added to toolbar button events we create and dispatch + if (e.__reactScanSyntheticEvent) { + return; + } + + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas || !refEventCatcher.current) return; + + if (isClickInLockIcon(e, canvas)) { + e.preventDefault(); + e.stopPropagation(); + handleLockIconClick(state); + return; + } + + if (state.kind === "inspecting") { + handleElementClick(e); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas) return; + + if (document.activeElement?.id === "react-scan-root") { + return; + } + + signalWidgetViews.value = { + view: "none", + }; + + if (state.kind === "focused" || state.kind === "inspecting") { + e.preventDefault(); + e.stopPropagation(); + + switch (state.kind) { + case "focused": { + startFadeIn(); + refCurrentRect.current = null; + refLastHoveredElement.current = state.focusedDomElement; + Store.inspectState.value = { + kind: "inspecting", + hoveredDomElement: state.focusedDomElement, + }; + break; + } + case "inspecting": { + startFadeOut(() => { + signalIsSettingsOpen.value = false; + Store.inspectState.value = { + kind: "inspect-off", + }; + }); + break; + } + } + } + }; + + const handleStateChange = ( + state: States, + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + ) => { + refCleanupMap.current.get(state.kind)?.(); + + if (refEventCatcher.current) { + if (state.kind !== "inspecting") { + refEventCatcher.current.style.pointerEvents = "none"; + } + } + + if (refRafId.current) { + cancelAnimationFrame(refRafId.current); + } + + let unsubReport: (() => void) | undefined; + + switch (state.kind) { + case "inspect-off": + startFadeOut(); + return; + + case "inspecting": + drawHoverOverlay(state.hoveredDomElement, canvas, ctx, "inspecting"); + break; + + case "focused": + if (!state.focusedDomElement) return; + + if (refLastHoveredElement.current !== state.focusedDomElement) { + refLastHoveredElement.current = state.focusedDomElement; + } + + signalWidgetViews.value = { + view: "inspector", + }; + + drawHoverOverlay(state.focusedDomElement, canvas, ctx, "locked"); + + unsubReport = Store.lastReportTime.subscribe(() => { + if (refRafId.current && refCurrentRect.current) { + const { parentCompositeFiber } = getCompositeComponentFromElement( + state.focusedDomElement, + ); + if (parentCompositeFiber) { + drawHoverOverlay(state.focusedDomElement, canvas, ctx, "locked"); + } + } + }); + + if (unsubReport) { + refCleanupMap.current.set(state.kind, unsubReport); + } + break; + } + }; + + const updateCanvasSize = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * OVERLAY_DPR; + canvas.height = rect.height * OVERLAY_DPR; + ctx.scale(OVERLAY_DPR, OVERLAY_DPR); + ctx.save(); + }; + + const handleResizeOrScroll = () => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas) return; + const ctx = canvas?.getContext("2d"); + if (!ctx) return; + + cancelAnimationFrame(refRafId.current); + clearTimeout(refTimeout.current); + + updateCanvasSize(canvas, ctx); + refCurrentRect.current = null; + + if (state.kind === "focused" && state.focusedDomElement) { + drawHoverOverlay(state.focusedDomElement, canvas, ctx, "locked"); + } else if (state.kind === "inspecting" && state.hoveredDomElement) { + drawHoverOverlay(state.hoveredDomElement, canvas, ctx, "inspecting"); + } + }; + + const handlePointerDown = (e: PointerEvent) => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas) return; + + if (state.kind === "inspecting" || isClickInLockIcon(e as unknown as MouseEvent, canvas)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + }; + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const canvas = refCanvas.current; + if (!canvas) return; + const ctx = canvas?.getContext("2d"); + if (!ctx) return; + + updateCanvasSize(canvas, ctx); + + const unSubState = Store.inspectState.subscribe((state) => { + handleStateChange(state, canvas, ctx); + }); + + window.addEventListener("scroll", handleResizeOrScroll, { passive: true }); + window.addEventListener("resize", handleResizeOrScroll, { passive: true }); + document.addEventListener("pointermove", handlePointerMove, { + passive: true, + capture: true, + }); + document.addEventListener("pointerdown", handlePointerDown, { + capture: true, + }); + document.addEventListener("click", handleClick, { capture: true }); + document.addEventListener("keydown", handleKeyDown, { capture: true }); + + return () => { + unsubscribeAll(); + unSubState(); + window.removeEventListener("scroll", handleResizeOrScroll); + window.removeEventListener("resize", handleResizeOrScroll); + document.removeEventListener("pointermove", handlePointerMove, { + capture: true, + }); + document.removeEventListener("click", handleClick, { capture: true }); + document.removeEventListener("pointerdown", handlePointerDown, { + capture: true, + }); + document.removeEventListener("keydown", handleKeyDown, { capture: true }); + + if (refRafId.current) { + cancelAnimationFrame(refRafId.current); + } + clearTimeout(refTimeout.current); + }; + }, []); + + return ( + <> +
+ + + ); +}; diff --git a/packages/scan-react/src/web/views/inspector/states.ts b/packages/scan-react/src/web/views/inspector/states.ts new file mode 100644 index 00000000..1d980663 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/states.ts @@ -0,0 +1,166 @@ +import type { Fiber } from "bippy"; +import type { ComponentType } from "react"; +import { signal } from "~web/utils/signals"; +import type { SectionData } from "./timeline/utils"; + +export interface MinimalFiberInfo { + id?: string | number; + key: string | null; + type: ComponentType | string; + displayName: string; + selfTime: number; + totalTime: number; +} + +export interface TimelineUpdate { + timestamp: number; + fiberInfo: MinimalFiberInfo; + props: SectionData; + state: SectionData; + context: SectionData; + stateNames: string[]; +} + +export interface TimelineState { + updates: Array; + currentFiber: Fiber | null; + totalUpdates: number; + windowOffset: number; + currentIndex: number; + isViewingHistory: boolean; + latestFiber: Fiber | null; + isVisible: boolean; + playbackSpeed: 1 | 2 | 4; +} + +export const TIMELINE_MAX_UPDATES = 1000; + +const timelineStateDefault: TimelineState = { + updates: [], + currentFiber: null, + totalUpdates: 0, + windowOffset: 0, + currentIndex: 0, + isViewingHistory: false, + latestFiber: null, + isVisible: false, + playbackSpeed: 1, +}; + +export const timelineState = signal(timelineStateDefault); + +export const inspectorUpdateSignal = signal(0); + +let pendingUpdates: Array<{ update: TimelineUpdate; fiber: Fiber | null }> = []; +let batchTimeout: ReturnType | null = null; + +const batchUpdates = () => { + if (pendingUpdates.length === 0) return; + + const batchedUpdates = [...pendingUpdates]; + + const { updates, totalUpdates, currentIndex, isViewingHistory } = timelineState.value; + const newUpdates = [...updates]; + let newTotalUpdates = totalUpdates; + + for (const { update } of batchedUpdates) { + if (newUpdates.length >= TIMELINE_MAX_UPDATES) { + newUpdates.shift(); + } + newUpdates.push(update); + newTotalUpdates++; + } + + const newWindowOffset = Math.max(0, newTotalUpdates - TIMELINE_MAX_UPDATES); + + let newCurrentIndex: number; + if (isViewingHistory) { + if (currentIndex === totalUpdates - 1) { + newCurrentIndex = newUpdates.length - 1; + } else if (currentIndex === 0) { + newCurrentIndex = 0; + } else { + if (newWindowOffset === 0) { + newCurrentIndex = currentIndex; + } else { + newCurrentIndex = currentIndex - 1; + } + } + } else { + newCurrentIndex = newUpdates.length - 1; + } + + const lastUpdate = batchedUpdates[batchedUpdates.length - 1]; + + timelineState.value = { + ...timelineState.value, + latestFiber: lastUpdate.fiber, + updates: newUpdates, + totalUpdates: newTotalUpdates, + windowOffset: newWindowOffset, + currentIndex: newCurrentIndex, + isViewingHistory, + }; + + // Only after signal is updated, remove the processed updates + pendingUpdates = pendingUpdates.slice(batchedUpdates.length); +}; + +export const timelineActions = { + showTimeline: () => { + timelineState.value = { + ...timelineState.value, + isVisible: true, + }; + }, + + hideTimeline: () => { + timelineState.value = { + ...timelineState.value, + isVisible: false, + currentIndex: timelineState.value.updates.length - 1, + }; + }, + + updateFrame: (index: number, isViewingHistory: boolean) => { + timelineState.value = { + ...timelineState.value, + currentIndex: index, + isViewingHistory, + }; + }, + + updatePlaybackSpeed: (speed: TimelineState["playbackSpeed"]) => { + timelineState.value = { + ...timelineState.value, + playbackSpeed: speed, + }; + }, + + addUpdate: (update: TimelineUpdate, latestFiber: Fiber | null) => { + pendingUpdates.push({ update, fiber: latestFiber }); + + if (!batchTimeout) { + const processBatch = () => { + batchUpdates(); + + batchTimeout = null; + + if (pendingUpdates.length > 0) { + batchTimeout = setTimeout(processBatch, 96); + } + }; + + batchTimeout = setTimeout(processBatch, 96); + } + }, + + reset: () => { + if (batchTimeout) { + clearTimeout(batchTimeout); + batchTimeout = null; + } + pendingUpdates = []; + timelineState.value = timelineStateDefault; + }, +}; diff --git a/packages/scan-react/src/web/views/inspector/timeline/utils.ts b/packages/scan-react/src/web/views/inspector/timeline/utils.ts new file mode 100644 index 00000000..6d47212e --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/timeline/utils.ts @@ -0,0 +1,502 @@ +import { + ClassComponentTag, + type ContextDependency, + type Fiber, + ForwardRefTag, + FunctionComponentTag, + MemoComponentTag, + type MemoizedState, + SimpleMemoComponentTag, +} from "bippy"; +import { isEqual } from "~core/utils"; +import { getChangedPropsDetailed, isPromise } from "../utils"; + +interface ChangeTrackingInfo { + count: number; + currentValue: unknown; + previousValue: unknown; + lastUpdated: number; +} + +type ChangeKey = string | number; + +const propsTracker = new Map(); +const stateTracker = new Map(); +const contextTracker = new Map(); +let lastComponentType: unknown = null; + +const STATE_NAME_REGEX = /\[(?\w+),\s*set\w+\]/g; +export const getStateNames = (fiber: Fiber): Array => { + const componentSource = fiber.type?.toString?.() || ""; + return componentSource + ? Array.from( + componentSource.matchAll(STATE_NAME_REGEX), + (m: RegExpMatchArray) => m.groups?.name ?? "", + ) + : []; +}; + +export const resetTracking = () => { + propsTracker.clear(); + stateTracker.clear(); + contextTracker.clear(); + lastComponentType = null; +}; + +const isInitialComponentUpdate = (fiber: Fiber): boolean => { + const isNewComponent = fiber.type !== lastComponentType; + lastComponentType = fiber.type; + return isNewComponent; +}; + +const trackChange = ( + tracker: Map, + key: ChangeKey, + currentValue: unknown, + previousValue: unknown, +): { hasChanged: boolean; count: number } => { + const existing = tracker.get(key); + const isInitialValue = tracker === propsTracker || tracker === contextTracker; + const hasChanged = !isEqual(currentValue, previousValue); + + if (!existing) { + // For props and context, start with count 1 if there's a change + tracker.set(key, { + count: hasChanged && isInitialValue ? 1 : 0, + currentValue, + previousValue, + lastUpdated: Date.now(), + }); + + return { + hasChanged, + count: hasChanged && isInitialValue ? 1 : isInitialValue ? 0 : 1, + }; + } + + if (!isEqual(existing.currentValue, currentValue)) { + const newCount = existing.count + 1; + tracker.set(key, { + count: newCount, + currentValue, + previousValue: existing.currentValue, + lastUpdated: Date.now(), + }); + return { hasChanged: true, count: newCount }; + } + + return { hasChanged: false, count: existing.count }; +}; + +export { propsTracker, stateTracker, contextTracker }; + +export interface SectionData { + current: Array<{ name: string | number; value: unknown }>; + changes: Set; + changesCounts: Map; +} + +export interface InspectorData { + fiberProps: SectionData; + fiberState: SectionData; + fiberContext: SectionData; +} + +const getStateFromFiber = (fiber: Fiber): Record => { + if (!fiber) return {}; + + if ( + fiber.tag === FunctionComponentTag || + fiber.tag === ForwardRefTag || + fiber.tag === SimpleMemoComponentTag || + fiber.tag === MemoComponentTag + ) { + let memoizedState: MemoizedState | null = fiber.memoizedState; + const state: Record = {}; + let index = 0; + + while (memoizedState) { + if (memoizedState.queue && memoizedState.memoizedState !== undefined) { + state[index] = memoizedState.memoizedState; + } + memoizedState = memoizedState.next; + index++; + } + + return state; + } + + if (fiber.tag === ClassComponentTag) { + return fiber.memoizedState || {}; + } + + return {}; +}; + +export interface InspectorDataResult { + data: InspectorData; + shouldUpdate: boolean; +} + +interface BaseChange { + name: string | number; + value: unknown; + prevValue: unknown; +} + +interface PropChange extends BaseChange { + name: string; +} + +interface StateChange extends BaseChange { + name: string | number; +} + +interface ContextChange extends BaseChange { + name: string; + contextType: unknown; +} + +interface CollectorResult { + current: Record; + prev: Record; + changes: Array; +} + +export const collectPropsChanges = (fiber: Fiber): CollectorResult => { + const currentProps = fiber.memoizedProps || {}; + const prevProps = fiber.alternate?.memoizedProps || {}; + + const current: Record = {}; + const prev: Record = {}; + + const allProps = Object.keys(currentProps); + for (const key of allProps) { + if (key in currentProps) { + current[key] = currentProps[key]; + prev[key] = prevProps[key]; + } + } + + const changes = getChangedPropsDetailed(fiber).map((change) => ({ + name: change.name, + value: change.value, + prevValue: change.prevValue, + })); + + return { current, prev, changes }; +}; + +export const collectStateChanges = (fiber: Fiber): CollectorResult => { + const current = getStateFromFiber(fiber); + const prev = fiber.alternate ? getStateFromFiber(fiber.alternate) : {}; + const changes: Array = []; + + for (const [index, value] of Object.entries(current)) { + const stateKey = fiber.tag === ClassComponentTag ? index : Number(index); + if (fiber.alternate && !isEqual(prev[index], value)) { + changes.push({ + name: stateKey, + value, + prevValue: prev[index], + }); + } + } + + return { current, prev, changes }; +}; + +export const collectContextChanges = (fiber: Fiber): CollectorResult => { + const currentContexts = getAllFiberContexts(fiber); + const prevContexts = fiber.alternate ? getAllFiberContexts(fiber.alternate) : new Map(); + + const current: Record = {}; + const prev: Record = {}; + const changes: Array = []; + + const seenContexts = new Set(); + for (const [contextType, ctx] of currentContexts) { + const name = ctx.displayName; + const contextKey = contextType; + + if (seenContexts.has(contextKey)) continue; + seenContexts.add(contextKey); + + current[name] = ctx.value; + + const prevCtx = prevContexts.get(contextType); + if (prevCtx) { + prev[name] = prevCtx.value; + if (!isEqual(prevCtx.value, ctx.value)) { + changes.push({ + name, + value: ctx.value, + prevValue: prevCtx.value, + contextType, + }); + } + } + } + + return { current, prev, changes }; +}; + +export const collectInspectorData = (fiber: Fiber): InspectorDataResult => { + const emptySection = (): SectionData => ({ + current: [], + changes: new Set(), + changesCounts: new Map(), + }); + + if (!fiber) { + return { + data: { + fiberProps: emptySection(), + fiberState: emptySection(), + fiberContext: emptySection(), + }, + shouldUpdate: false, + }; + } + + let hasNewChanges = false; + const isInitialUpdate = isInitialComponentUpdate(fiber); + + const propsData = emptySection(); + if (fiber.memoizedProps) { + const { current, changes } = collectPropsChanges(fiber); + + for (const [key, value] of Object.entries(current)) { + propsData.current.push({ + name: key, + value: isPromise(value) ? { type: "promise", displayValue: "Promise" } : value, + }); + } + + for (const change of changes) { + const { hasChanged, count } = trackChange( + propsTracker, + change.name, + change.value, + change.prevValue, + ); + + if (hasChanged) { + hasNewChanges = true; + propsData.changes.add(change.name); + propsData.changesCounts.set(change.name, count); + } + } + } + + const stateData = emptySection(); + const { current: stateCurrent, changes: stateChanges } = collectStateChanges(fiber); + + for (const [index, value] of Object.entries(stateCurrent)) { + const stateKey = fiber.tag === ClassComponentTag ? index : Number(index); + stateData.current.push({ name: stateKey, value }); + } + + for (const change of stateChanges) { + const { hasChanged, count } = trackChange( + stateTracker, + change.name, + change.value, + change.prevValue, + ); + + if (hasChanged) { + hasNewChanges = true; + stateData.changes.add(change.name); + stateData.changesCounts.set(change.name, count); + } + } + + const contextData = emptySection(); + const { current: contextCurrent, changes: contextChanges } = collectContextChanges(fiber); + + for (const [name, value] of Object.entries(contextCurrent)) { + contextData.current.push({ name, value }); + } + + if (!isInitialUpdate) { + for (const change of contextChanges) { + const { hasChanged, count } = trackChange( + contextTracker, + change.name, + change.value, + change.prevValue, + ); + + if (hasChanged) { + hasNewChanges = true; + contextData.changes.add(change.name); + contextData.changesCounts.set(change.name, count); + } + } + } + + if (!hasNewChanges && !isInitialUpdate) { + propsData.changes.clear(); + stateData.changes.clear(); + contextData.changes.clear(); + } + + return { + data: { + fiberProps: propsData, + fiberState: stateData, + fiberContext: contextData, + }, + shouldUpdate: hasNewChanges || isInitialUpdate, + }; +}; + +interface ContextInfo { + value: unknown; + displayName: string; + contextType: unknown; +} +// hm we potentially want to revalidate this if a fiber has new context's, i'm not sure how we can do that reactively +// i suppose we can do one traversal on render (or during the existing traversal) that checks if any new context providers were mounted +// and when that happens we revalidate this cache + +// i suppose a case this breaks is if a fiber changes ancestors through a key but doesn't remount +// then it would have new parents... and that new parent may have new context +// may be a fine trade off +// the motivation is this fiber traversal on every rendering fiber is extremely expensive +const fiberContextsCache = new WeakMap>(); + +export const getAllFiberContexts = (fiber: Fiber): Map => { + if (!fiber) { + return new Map(); + } + + // todo validate this works + + const cachedContexts = fiberContextsCache.get(fiber); + if (cachedContexts) { + return cachedContexts; + } + + const contexts = new Map(); + let currentFiber: Fiber | null = fiber; + + while (currentFiber) { + const dependencies = currentFiber.dependencies; + + if (dependencies?.firstContext) { + let contextItem: ContextDependency | null = dependencies.firstContext; + + while (contextItem) { + const memoizedValue = contextItem.memoizedValue; + const displayName = contextItem.context?.displayName; + + if (!contexts.has(memoizedValue)) { + contexts.set(contextItem.context, { + value: memoizedValue, + displayName: displayName ?? "UnnamedContext", + contextType: null, + }); + } + + if (contextItem === contextItem.next) { + break; + } + + contextItem = contextItem.next; + } + } + + currentFiber = currentFiber.return; + } + + // Cache the result for this fiber + fiberContextsCache.set(fiber, contexts); + + return contexts; +}; + +export const collectInspectorDataWithoutCounts = (fiber: Fiber) => { + const emptySection = (): SectionData => ({ + current: [], + changes: new Set(), + changesCounts: new Map(), + }); + + if (!fiber) { + return { + fiberProps: emptySection(), + fiberState: emptySection(), + fiberContext: emptySection(), + }; + } + + // let hasNewChanges = false; + + const propsData = emptySection(); + if (fiber.memoizedProps) { + const { current, changes } = collectPropsChanges(fiber); + + for (const [key, value] of Object.entries(current)) { + propsData.current.push({ + name: key, + value: isPromise(value) ? { type: "promise", displayValue: "Promise" } : value, + }); + } + + for (const change of changes) { + // hasNewChanges = true; + propsData.changes.add(change.name); + propsData.changesCounts.set(change.name, 1); + } + } + + const stateData = emptySection(); + if (fiber.memoizedState) { + const { current, changes } = collectStateChanges(fiber); + + for (const [key, value] of Object.entries(current)) { + stateData.current.push({ + name: key, + value: isPromise(value) ? { type: "promise", displayValue: "Promise" } : value, + }); + } + + for (const change of changes) { + // hasNewChanges = true; + stateData.changes.add(change.name); + stateData.changesCounts.set(change.name, 1); + } + } + + const contextData = emptySection(); + const { current, changes } = collectContextChanges(fiber); + + for (const [key, value] of Object.entries(current)) { + contextData.current.push({ + name: key, + value: isPromise(value) ? { type: "promise", displayValue: "Promise" } : value, + }); + } + + for (const change of changes) { + // hasNewChanges = true; + contextData.changes.add(change.name); + contextData.changesCounts.set(change.name, 1); + } + // todo: is isInitialUpdate correct? Is this necessary: + // if (!hasNewChanges && !isInitialUpdate) { + // propsData.changes.clear(); + // stateData.changes.clear(); + // contextData.changes.clear(); + // } + + return { + // data: { + fiberProps: propsData, + fiberState: stateData, + fiberContext: contextData, + // }, + }; +}; diff --git a/packages/scan-react/src/web/views/inspector/utils.ts b/packages/scan-react/src/web/views/inspector/utils.ts new file mode 100644 index 00000000..7da657db --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/utils.ts @@ -0,0 +1,1849 @@ +import { + type Fiber, + FunctionComponentTag, + type MemoizedState, + type ReactRenderer, + getDisplayName, + getTimings, + isCompositeFiber, + isHostFiber, + traverseFiber, +} from "bippy"; +import { type PropsChange, ReactScanInternals } from "~core/index"; +import { ChangeReason } from "~core/instrumentation"; +import { isEqual } from "~core/utils"; +import { globalInspectorState } from "."; +import { TIMELINE_MAX_UPDATES } from "./states"; +import type { MinimalFiberInfo } from "./states"; +import { getAllFiberContexts, getStateNames } from "./timeline/utils"; + +interface StateItem { + name: string; + value: unknown; +} + +// todo, change this to currently focused fiber +export type States = + | { + kind: "inspecting"; + hoveredDomElement: Element | null; + } + | { + kind: "inspect-off"; + } + | { + kind: "focused"; + focusedDomElement: Element; + fiber: Fiber; + } + | { + kind: "uninitialized"; + }; + +interface ReactRootContainer { + _reactRootContainer?: { + _internalRoot?: { + current?: { + child: Fiber; + }; + }; + }; +} + +interface ReactInternalProps { + [key: string]: Fiber; +} + +export const getFiberFromElement = (element: Element): Fiber | null => { + if ("__REACT_DEVTOOLS_GLOBAL_HOOK__" in window) { + const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook?.renderers) return null; + + for (const [, renderer] of Array.from(hook.renderers)) { + try { + const fiber = renderer.findFiberByHostInstance?.(element); + if (fiber) return fiber; + } catch { + // If React is mid-render, references to previous nodes may disappear + } + } + } + + if ("_reactRootContainer" in element) { + const elementWithRoot = element as unknown as ReactRootContainer; + const rootContainer = elementWithRoot._reactRootContainer; + return rootContainer?._internalRoot?.current?.child ?? null; + } + + for (const key in element) { + if (key.startsWith("__reactInternalInstance$") || key.startsWith("__reactFiber")) { + const elementWithFiber = element as unknown as ReactInternalProps; + return elementWithFiber[key]; + } + } + return null; +}; + +const getFirstStateNode = (fiber: Fiber): Element | null => { + let current: Fiber | null = fiber; + while (current) { + if (current.stateNode instanceof Element) { + return current.stateNode; + } + + if (!current.child) { + break; + } + current = current.child; + } + + while (current) { + if (current.stateNode instanceof Element) { + return current.stateNode; + } + + if (!current.return) { + break; + } + current = current.return; + } + return null; +}; + +const getNearestFiberFromElement = (element: Element | null): Fiber | null => { + if (!element) return null; + + try { + const fiber = getFiberFromElement(element); + if (!fiber) return null; + + const res = getParentCompositeFiber(fiber); + return res ? res[0] : null; + } catch { + return null; + } +}; + +export const getParentCompositeFiber = (fiber: Fiber): readonly [Fiber, Fiber | null] | null => { + let current: Fiber | null = fiber; + let prevHost: Fiber | null = null; + + while (current) { + if (isCompositeFiber(current)) return [current, prevHost] as const; + if (isHostFiber(current) && !prevHost) prevHost = current; + current = current.return; + } + + return null; +}; + +const isFiberInTree = (fiber: Fiber, root: Fiber): boolean => { + { + // const root= fiberRootCache.get(fiber) || (fiber.alternate && fiberRootCache.get(fiber.alternate) ) + // if (root){ + // return root + // } + const res = !!traverseFiber(root, (searchFiber) => searchFiber === fiber); + + return res; + } +}; + +const isCurrentTree = (fiber: Fiber) => { + let curr: Fiber | null = fiber; + let rootFiber: Fiber | null = null; + + while (curr) { + // todo: make sure removing null check doesn't break + // todo: document that fiber stores root in stateNode + if (!curr.stateNode) { + curr = curr.return; + continue; + } + // if the app never rendered then fiber roots will always return false, but thats fine since we don't care which + // fiber we read from when there never has been a re-render + // todo: document that better + if (ReactScanInternals.instrumentation?.fiberRoots.has(curr.stateNode)) { + rootFiber = curr; + + break; + } + + curr = curr.return; + } + + if (!rootFiber) { + return false; + } + + const fiberRoot = rootFiber.stateNode; + const currentRootFiber = fiberRoot.current; + + return isFiberInTree(fiber, currentRootFiber); +}; + +export const getAssociatedFiberRect = async (element: Element) => { + const associatedFiber = getNearestFiberFromElement(element); + + if (!associatedFiber) return null; + const stateNode = getFirstStateNode(associatedFiber); + if (!stateNode) 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; +}; + +// todo-before-stable(rob): refactor these +export const getCompositeComponentFromElement = (element: Element) => { + const associatedFiber = getNearestFiberFromElement(element); + + if (!associatedFiber) return {}; + + const stateNode = getFirstStateNode(associatedFiber); + if (!stateNode) return {}; + const parentCompositeFiberInfo = getParentCompositeFiber(associatedFiber); + if (!parentCompositeFiberInfo) { + return {}; + } + const [parentCompositeFiber] = parentCompositeFiberInfo; + + return { + parentCompositeFiber, + }; +}; + +export const getCompositeFiberFromElement = (element: Element, knownFiber?: Fiber) => { + if (!element.isConnected) return {}; + + let fiber = knownFiber ?? getNearestFiberFromElement(element); + if (!fiber) return {}; + + // Find root once and cache it + let curr: Fiber | null = fiber; + let rootFiber: Fiber | null = null; + let currentRootFiber: Fiber | null = null; + + while (curr) { + if (!curr.stateNode) { + curr = curr.return; + continue; + } + if (ReactScanInternals.instrumentation?.fiberRoots.has(curr.stateNode)) { + rootFiber = curr; + currentRootFiber = curr.stateNode.current; + break; + } + curr = curr.return; + } + + if (!rootFiber || !currentRootFiber) return {}; + + // Get the current associated fiber using cached root + fiber = isFiberInTree(fiber, currentRootFiber) ? fiber : (fiber.alternate ?? fiber); + if (!fiber) return {}; + + if (!getFirstStateNode(fiber)) return {}; + + // Get parent composite fiber + const parentCompositeFiber = getParentCompositeFiber(fiber)?.[0]; + if (!parentCompositeFiber) return {}; + + // Use cached root to check parent fiber + return { + parentCompositeFiber: isFiberInTree(parentCompositeFiber, currentRootFiber) + ? parentCompositeFiber + : (parentCompositeFiber.alternate ?? parentCompositeFiber), + }; +}; + +export const getChangedPropsDetailed = (fiber: Fiber): Array => { + const currentProps = fiber.memoizedProps ?? {}; + const previousProps = fiber.alternate?.memoizedProps ?? {}; + const changes: Array = []; + + for (const key in currentProps) { + if (key === "children") continue; + + const currentValue = currentProps[key]; + const prevValue = previousProps[key]; + + if (!isEqual(currentValue, prevValue)) { + changes.push({ + name: key, + value: currentValue, + prevValue, + type: ChangeReason.Props, + }); + } + } + + return changes; +}; + +export interface OverrideMethods { + overrideProps: ((fiber: Fiber, path: string[], value: unknown) => void) | null; + overrideHookState: ((fiber: Fiber, id: string, path: string[], value: unknown) => void) | null; + overrideContext: ((fiber: Fiber, contextType: unknown, value: unknown) => void) | null; +} + +const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === "object"; +}; + +const getOverrideMethods = (): OverrideMethods => { + let overrideProps: OverrideMethods["overrideProps"] = null; + let overrideHookState: OverrideMethods["overrideHookState"] = null; + let overrideContext: OverrideMethods["overrideContext"] = null; + + if ("__REACT_DEVTOOLS_GLOBAL_HOOK__" in window) { + const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook?.renderers) { + return { + overrideProps: null, + overrideHookState: null, + overrideContext: null, + }; + } + + for (const [, renderer] of Array.from(hook.renderers)) { + try { + const devToolsRenderer = renderer as ReactRenderer; + + if (overrideHookState) { + const prevOverrideHookState = overrideHookState; + overrideHookState = (fiber: Fiber, id: string, path: string[], value: unknown) => { + // Find the hook + let current = fiber.memoizedState; + for (let i = 0; i < Number(id); i++) { + if (!current?.next) break; + current = current.next; + } + + if (current?.queue) { + // Update through React's queue mechanism + const queue = current.queue; + if (isRecord(queue) && "dispatch" in queue) { + const dispatch = queue.dispatch as (value: unknown) => void; + dispatch(value); + return; + } + } + + // Chain updates through all renderers to ensure consistency across different React renderers + // (e.g., React DOM + React Native Web in the same app) + prevOverrideHookState(fiber, id, path, value); + devToolsRenderer.overrideHookState?.(fiber, id, path, value); + }; + } else if (devToolsRenderer.overrideHookState) { + overrideHookState = devToolsRenderer.overrideHookState; + } + + if (overrideProps) { + const prevOverrideProps = overrideProps; + overrideProps = (fiber: Fiber, path: Array, value: unknown) => { + // Chain updates through all renderers to maintain consistency + prevOverrideProps(fiber, path, value); + devToolsRenderer.overrideProps?.(fiber, path, value); + }; + } else if (devToolsRenderer.overrideProps) { + overrideProps = devToolsRenderer.overrideProps; + } + + // For context, we don't need the chaining pattern since we're using overrideProps internally + // to update the context provider's value prop, which already handles the chaining + overrideContext = (fiber: Fiber, contextType: unknown, value: unknown) => { + // Find the provider fiber for this context + let current: Fiber | null = fiber; + while (current) { + const type = current.type as { Provider?: unknown }; + if (type === contextType || type?.Provider === contextType) { + // Found the provider, update both current and alternate fibers + if (overrideProps) { + overrideProps(current, ["value"], value); + if (current.alternate) { + overrideProps(current.alternate, ["value"], value); + } + } + break; + } + current = current.return; + } + }; + } catch { + /**/ + } + } + } + + return { overrideProps, overrideHookState, overrideContext }; +}; + +export const nonVisualTags = new Set([ + "HTML", + "HEAD", + "META", + "TITLE", + "BASE", + "SCRIPT", + "SCRIPT", + "STYLE", + "LINK", + "NOSCRIPT", + "SOURCE", + "TRACK", + "EMBED", + "OBJECT", + "PARAM", + "TEMPLATE", + "PORTAL", + "SLOT", + "AREA", + "XML", + "DOCTYPE", + "COMMENT", +]); + +export const findComponentDOMNode = ( + fiber: Fiber, + excludeNonVisualTags = true, +): HTMLElement | null => { + if (fiber.stateNode && "nodeType" in fiber.stateNode) { + const element = fiber.stateNode as HTMLElement; + if ( + excludeNonVisualTags && + element.tagName && + nonVisualTags.has(element.tagName.toLowerCase()) + ) { + return null; + } + return element; + } + + let child = fiber.child; + while (child) { + const result = findComponentDOMNode(child, excludeNonVisualTags); + if (result) return result; + child = child.sibling; + } + + return null; +}; + +export interface InspectableElement { + element: HTMLElement; + depth: number; + name: string; + fiber: Fiber; +} + +export const getInspectableElements = ( + root: HTMLElement = document.body, +): Array => { + const result: Array = []; + + const findInspectableFiber = (element: HTMLElement | null): HTMLElement | null => { + if (!element) return null; + + const { parentCompositeFiber } = getCompositeComponentFromElement(element); + if (!parentCompositeFiber) return null; + + const componentRoot = findComponentDOMNode(parentCompositeFiber); + return componentRoot === element ? element : null; + }; + + const traverse = (element: HTMLElement, depth = 0) => { + const inspectable = findInspectableFiber(element); + if (inspectable) { + const { parentCompositeFiber } = getCompositeComponentFromElement(inspectable); + + if (!parentCompositeFiber) return; + + result.push({ + element: inspectable, + depth, + name: getDisplayName(parentCompositeFiber.type) ?? "Unknown", + fiber: parentCompositeFiber, + }); + } + + // Traverse children first (depth-first) + for (const child of Array.from(element.children)) { + traverse(child as HTMLElement, inspectable ? depth + 1 : depth); + } + }; + + traverse(root); + return result; +}; + +const fiberMap = new WeakMap(); + +const getInspectableAncestors = (element: HTMLElement): Array => { + const result: Array = []; + + const findInspectableFiber = (element: HTMLElement | null): HTMLElement | null => { + if (!element) return null; + const { parentCompositeFiber } = getCompositeComponentFromElement(element); + if (!parentCompositeFiber) return null; + + const componentRoot = findComponentDOMNode(parentCompositeFiber); + if (componentRoot === element) { + // Store the fiber reference in WeakMap + fiberMap.set(element, parentCompositeFiber); + return element; + } + return null; + }; + + let current: HTMLElement | null = element; + while (current && current !== document.body) { + const inspectable = findInspectableFiber(current); + if (inspectable) { + // Get fiber from WeakMap + const fiber = fiberMap.get(inspectable); + if (fiber) { + result.unshift({ + element: inspectable, + depth: 0, + name: getDisplayName(fiber.type) ?? "Unknown", + fiber, + }); + } + } + current = current.parentElement; + } + + return result; +}; + +type DiffResult = { + type: "primitive" | "reference" | "object"; + changes: Array<{ + path: string[]; + prevValue: unknown; + currentValue: unknown; + sameFunction?: boolean; + }>; + hasDeepChanges: boolean; +}; + +type DiffChange = { + path: string[]; + prevValue: unknown; + currentValue: unknown; + sameFunction?: boolean; +}; + +type InspectableValue = + | Record + | Array + | Map + | Set + | ArrayBuffer + | DataView + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +export type AggregatedChanges = { + count: number; + // unstable: boolean; + currentValue: unknown; + previousValue: unknown; + // displayName?:string + name: string; +}; + +const isExpandable = (value: unknown): value is InspectableValue => { + if (value === null || typeof value !== "object" || isPromise(value)) { + return false; + } + + if (value instanceof ArrayBuffer) { + return true; + } + + if (value instanceof DataView) { + return true; + } + + if (ArrayBuffer.isView(value)) { + return true; + } + + if (value instanceof Map || value instanceof Set) { + return value.size > 0; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return Object.keys(value).length > 0; +}; + +const isEditableValue = (value: unknown, parentPath?: string): boolean => { + if (value == null) return true; + + if (isPromise(value)) return false; + + if (typeof value === "function") { + return false; + } + + if (parentPath) { + const parts = parentPath.split("."); + let currentPath = ""; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}.${part}` : part; + const obj = globalInspectorState.lastRendered.get(currentPath); + if (obj instanceof DataView || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { + return false; + } + } + } + + switch (value.constructor) { + case Date: + case RegExp: + case Error: + return true; + default: + switch (typeof value) { + case "string": + case "number": + case "boolean": + case "bigint": + return true; + default: + return false; + } + } +}; + +const getPath = ( + componentName: string, + section: string, + parentPath: string, + key: string, +): string => { + if (parentPath) { + return `${componentName}.${parentPath}.${key}`; + } + + if (section === "context" && !key.startsWith("context.")) { + return `${componentName}.${section}.context.${key}`; + } + + return `${componentName}.${section}.${key}`; +}; + +const sanitizeString = (value: string): string => { + return value + .replace(/[<>]/g, "") + .replace(/javascript:/gi, "") + .replace(/data:/gi, "") + .replace(/on\w+=/gi, "") + .slice(0, 50000); +}; + +const sanitizeErrorMessage = (error: string): string => { + return error + .replace(/[<>]/g, "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\//g, "/"); +}; + +const formatValue = (value: unknown): string => { + const metadata = ensureRecord(value); + return metadata.displayValue as string; +}; + +export const formatForClipboard = (value: unknown): string => { + try { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (isPromise(value)) return "Promise"; + + if (typeof value === "function") { + const fnStr = value.toString(); + try { + const formatted = fnStr + .replace(/\s+/g, " ") // Normalize whitespace + .replace(/{\s+/g, "{\n ") // Add newline after { + .replace(/;\s+/g, ";\n ") // Add newline after ; + .replace(/}\s*$/g, "\n}") // Add newline before final } + .replace(/\(\s+/g, "(") // Remove space after ( + .replace(/\s+\)/g, ")") // Remove space before ) + .replace(/,\s+/g, ", "); // Normalize comma spacing + + return formatted; + } catch { + return fnStr; + } + } + + switch (true) { + case value instanceof Date: + return value.toISOString(); + case value instanceof RegExp: + return value.toString(); + case value instanceof Error: + return `${value.name}: ${value.message}`; + case value instanceof Map: + return JSON.stringify(Array.from(value.entries()), null, 2); + case value instanceof Set: + return JSON.stringify(Array.from(value), null, 2); + case value instanceof DataView: + return JSON.stringify(Array.from(new Uint8Array(value.buffer)), null, 2); + case value instanceof ArrayBuffer: + return JSON.stringify(Array.from(new Uint8Array(value)), null, 2); + case ArrayBuffer.isView(value) && "length" in value: + return JSON.stringify(Array.from(value as unknown as ArrayLike), null, 2); + case Array.isArray(value): + return JSON.stringify(value, null, 2); + case typeof value === "object": + return JSON.stringify(value, null, 2); + default: + return String(value); + } + } catch { + return String(value); + } +}; + +const parseArrayValue = (value: string): Array => { + if (value.trim() === "[]") return []; + + const result: Array = []; + let current = ""; + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + + if (char === "\\") { + escapeNext = true; + } + + if (char === '"') { + inString = !inString; + current += char; + continue; + } + + if (inString) { + current += char; + continue; + } + + if (char === "[" || char === "{") { + depth++; + current += char; + continue; + } + + if (char === "]" || char === "}") { + depth--; + current += char; + continue; + } + + if (char === "," && depth === 0) { + if (current.trim()) { + result.push(parseValue(current.trim(), "")); + } + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + result.push(parseValue(current.trim(), "")); + } + + return result; +}; + +const parseValue = (value: string, currentType: unknown): unknown => { + try { + switch (typeof currentType) { + case "number": + return Number(value); + case "string": + return value; + case "boolean": + return value === "true"; + case "bigint": + return BigInt(value); + case "undefined": + return undefined; + case "object": { + if (!currentType) { + return null; + } + + if (Array.isArray(currentType)) { + return parseArrayValue(value.slice(1, -1)); + } + + if (currentType instanceof RegExp) { + try { + const match = /^\/(?.*)\/(?[gimuy]*)$/.exec(value); + if (match?.groups) { + return new RegExp(match.groups.pattern, match.groups.flags); + } + return new RegExp(value); + } catch { + return currentType; + } + } + + if (currentType instanceof Map) { + const entries = value + .slice(1, -1) + .split(", ") + .map((entry) => { + const [key, val] = entry.split(" => "); + return [parseValue(key, ""), parseValue(val, "")] as [unknown, unknown]; + }); + return new Map(entries); + } + + if (currentType instanceof Set) { + const values = value + .slice(1, -1) + .split(", ") + .map((v) => parseValue(v, "")); + return new Set(values); + } + const entries = value + .slice(1, -1) + .split(", ") + .map((entry) => { + const [key, val] = entry.split(": "); + return [key, parseValue(val, "")]; + }); + return Object.fromEntries(entries); + } + } + + return value; + } catch { + return currentType; + } +}; + +const detectValueType = ( + value: string, +): { + type: "string" | "number" | "undefined" | "null" | "boolean"; + value: unknown; +} => { + const trimmed = value.trim(); + + switch (trimmed) { + case "undefined": + return { type: "undefined", value: undefined }; + case "null": + return { type: "null", value: null }; + case "true": + return { type: "boolean", value: true }; + case "false": + return { type: "boolean", value: false }; + } + + if (/^".*"$/.test(trimmed)) { + return { type: "string", value: trimmed.slice(1, -1) }; + } + + if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + return { type: "number", value: Number(trimmed) }; + } + + return { type: "string", value: `"${trimmed}"` }; +}; + +const formatInitialValue = (value: unknown): string => { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value === "string") return `"${value}"`; + return String(value); +}; + +const updateNestedValue = (obj: unknown, path: Array, value: unknown): unknown => { + try { + if (path.length === 0) return value; + + const [key, ...rest] = path; + + // Handle our special array of {name, value} pairs + if ( + Array.isArray(obj) && + obj.every((item): item is StateItem => "name" in item && "value" in item) + ) { + const index = obj.findIndex((item) => item.name === key); + if (index === -1) return obj; + + const newArray = [...obj]; + if (rest.length === 0) { + newArray[index] = { ...newArray[index], value }; + } else { + newArray[index] = { + ...newArray[index], + value: updateNestedValue(newArray[index].value, rest, value), + }; + } + return newArray; + } + + if (obj instanceof Map) { + const newMap = new Map(obj); + if (rest.length === 0) { + newMap.set(key, value); + } else { + const currentValue = newMap.get(key); + newMap.set(key, updateNestedValue(currentValue, rest, value)); + } + return newMap; + } + + if (Array.isArray(obj)) { + const index = Number.parseInt(key, 10); + const newArray = [...obj]; + if (rest.length === 0) { + newArray[index] = value; + } else { + newArray[index] = updateNestedValue(obj[index], rest, value); + } + return newArray; + } + + if (obj && typeof obj === "object") { + if (rest.length === 0) { + return { ...obj, [key]: value }; + } + return { + ...obj, + [key]: updateNestedValue((obj as Record)[key], rest, value), + }; + } + + return value; + } catch { + return obj; + } +}; + +const areFunctionsEqual = (prev: unknown, current: unknown): boolean => { + try { + // Check if both values are actually functions + if (typeof prev !== "function" || typeof current !== "function") { + return false; + } + + // Now we know both are functions, we can safely call toString() + return prev.toString() === current.toString(); + } catch { + return false; + } +}; + +export const getObjectDiff = ( + prev: unknown, + current: unknown, + path: string[] = [], + seen = new WeakSet(), +): DiffResult => { + if (prev === current) { + return { type: "primitive", changes: [], hasDeepChanges: false }; + } + + if (typeof prev === "function" && typeof current === "function") { + const isSameFunction = areFunctionsEqual(prev, current); + return { + type: "primitive", + changes: [ + { + path, + prevValue: prev, + currentValue: current, + sameFunction: isSameFunction, + }, + ], + hasDeepChanges: !isSameFunction, + }; + } + + if ( + prev === null || + current === null || + prev === undefined || + current === undefined || + typeof prev !== "object" || + typeof current !== "object" + ) { + return { + type: "primitive", + changes: [{ path, prevValue: prev, currentValue: current }], + hasDeepChanges: true, + }; + } + + if (seen.has(prev) || seen.has(current)) { + return { + type: "object", + changes: [{ path, prevValue: "[Circular]", currentValue: "[Circular]" }], + hasDeepChanges: false, + }; + } + + seen.add(prev); + seen.add(current); + + const prevObj = prev as Record; + const currentObj = current as Record; + const allKeys = new Set([...Object.keys(prevObj), ...Object.keys(currentObj)]); + const changes: Array = []; + let hasDeepChanges = false; + + for (const key of allKeys) { + const prevValue = prevObj[key]; + const currentValue = currentObj[key]; + + if (prevValue !== currentValue) { + if ( + typeof prevValue === "object" && + typeof currentValue === "object" && + prevValue !== null && + currentValue !== null + ) { + const nestedDiff = getObjectDiff(prevValue, currentValue, [...path, key], seen); + changes.push(...nestedDiff.changes); + if (nestedDiff.hasDeepChanges) { + hasDeepChanges = true; + } + } else { + changes.push({ + path: [...path, key], + prevValue, + currentValue, + }); + hasDeepChanges = true; + } + } + } + + return { + type: "object", + changes, + hasDeepChanges, + }; +}; + +export const formatPath = (path: string[]): string => { + if (path.length === 0) return ""; + + return path.reduce((acc, segment, i) => { + // Check if segment is a number (array index) + if (/^\d+$/.test(segment)) { + return `${acc}[${segment}]`; + } + // Add dot separator only if not first segment and previous segment wasn't an array index + return i === 0 ? segment : `${acc}.${segment}`; + }, ""); +}; + +const formatFunctionBody = (body: string): string => { + // Remove newlines and extra spaces + let formatted = body.replace(/\s+/g, " ").trim(); + + // Add newlines after {, ; and before } + formatted = formatted + .replace(/{/g, "{\n ") + .replace(/;/g, ";\n ") + .replace(/}/g, "\n}") + .replace(/{\s+}/g, "{ }"); // Clean up empty blocks + + // Clean up arrow functions + formatted = formatted.replace(/=> {\n/g, "=> {").replace(/\n\s*}\s*$/g, " }"); + + return formatted; +}; + +function hackyJsFormatter(code: string) { + // + // 1) Collapse runs of whitespace to single spaces + // + const normalizedCode = code.replace(/\s+/g, " ").trim(); + + // + // 2) Tokenize + // We'll separate out: + // - parentheses: ( ) + // - braces: { } + // - brackets: [ ] + // - angle brackets: < > + // - semicolon: ; + // - comma: , + // - arrow => + // - colon : + // - question mark ? + // - exclamation mark ! (for TS non-null etc.) + // + // We'll also try to combine () or [] or {} or <> if they appear empty. + // + const rawTokens = []; + let current = ""; + for (let i = 0; i < normalizedCode.length; i++) { + const c = normalizedCode[i]; + + // Detect arrow => + if (c === "=" && normalizedCode[i + 1] === ">") { + if (current.trim()) rawTokens.push(current.trim()); + rawTokens.push("=>"); + current = ""; + i++; + continue; + } + + // Single/double char punctuation + if (/[(){}[\];,<>:\?!]/.test(c)) { + // If we had something in current, push it + if (current.trim()) { + rawTokens.push(current.trim()); + } + rawTokens.push(c); + current = ""; + } else if (/\s/.test(c)) { + // whitespace ends the current token + if (current.trim()) { + rawTokens.push(current.trim()); + } + current = ""; + } else { + current += c; + } + } + if (current.trim()) { + rawTokens.push(current.trim()); + } + + // + // 3) Combine immediate pairs of empty brackets, e.g. '(' + ')' => '()' + // This helps keep arrow param empty parens on one line, etc. + // + const merged: Array = []; + for (let i = 0; i < rawTokens.length; i++) { + const t = rawTokens[i]; + const n = rawTokens[i + 1]; + if ( + (t === "(" && n === ")") || + (t === "[" && n === "]") || + (t === "{" && n === "}") || + (t === "<" && n === ">") + ) { + merged.push(t + n); // '()', '[]', '{}', '<>' + i++; + } else { + merged.push(t); + } + } + + // + // 4) We want to detect arrow param lists: + // i.e. "(" ... ")" immediately followed by "=>" + // so we can keep them on one line. + // + // Also, detect generic param lists: + // i.e. identifier "<" ... ">" (then maybe "(" ) for function calls or type declarations + // + // We'll store indexes in sets: arrowParamSet, genericSet + // + const arrowParamSet = new Set(); // indexes inside arrow param lists + const genericSet = new Set(); // indexes inside generics <...> + + function findMatchingPair(openTok: string, closeTok: string, startIndex: number) { + // e.g. openTok = '(', closeTok = ')' + let depth = 0; + for (let j = startIndex; j < merged.length; j++) { + const token = merged[j]; + if (token === openTok) depth++; + else if (token === closeTok) { + depth--; + if (depth === 0) return j; + } + } + return -1; + } + + // Detect arrow param sets + for (let i = 0; i < merged.length; i++) { + const t = merged[i]; + if (t === "(") { + const closeIndex = findMatchingPair("(", ")", i); + if (closeIndex !== -1 && merged[closeIndex + 1] === "=>") { + // Mark all tokens from i..closeIndex as arrow param + for (let k = i; k <= closeIndex; k++) { + arrowParamSet.add(k); + } + } + } + } + + // Detect generics, e.g. foo<...> or MyType<...> + // We do a naive approach: if we see something that looks like an identifier + // followed immediately by '<', we assume it's a generic. + for (let i = 1; i < merged.length; i++) { + const prev = merged[i - 1]; + const t = merged[i]; + // If prev is an identifier and t is '<', find matching '>' + if (/^[a-zA-Z0-9_$]+$/.test(prev) && t === "<") { + const closeIndex = findMatchingPair("<", ">", i); + if (closeIndex !== -1) { + // Mark i..closeIndex as generic + for (let k = i; k <= closeIndex; k++) { + genericSet.add(k); + } + } + } + } + + // + // 5) Build lines with indentation. We maintain a stack for open brackets. + // + let indentLevel = 0; + const indentStr = " "; // 2 spaces + const lines: Array = []; + let line = ""; + + function pushLine() { + if (line.trim()) { + lines.push(line.replace(/\s+$/, "")); + } + line = ""; + } + function newLine() { + pushLine(); + line = indentStr.repeat(indentLevel); + } + + const stack: Array = []; + function stackTop() { + return stack.length ? stack[stack.length - 1] : null; + } + + function placeToken(tok: string, noSpaceBefore = false) { + if (!line.trim()) { + // line is empty aside from indentation + line += tok; + } else { + if (noSpaceBefore || /^[),;:\].}>]$/.test(tok)) { + line += tok; + } else { + line += ` ${tok}`; + } + } + } + + for (let i = 0; i < merged.length; i++) { + const tok = merged[i]; + const next = merged[i + 1] || ""; + + // Open brackets + if (["(", "{", "[", "<"].includes(tok)) { + placeToken(tok); + stack.push(tok); + + // If '{', definitely newline + indent + if (tok === "{") { + indentLevel++; + newLine(); + } else if (tok === "(" || tok === "[" || tok === "<") { + // If we are in arrowParamSet or genericSet, keep it on one line + if ((arrowParamSet.has(i) && tok === "(") || (genericSet.has(i) && tok === "<")) { + // Don't break lines after commas etc. + // We won't do multiline logic for these. + } else { + // If next is not a direct close, go multiline + const directClose = { + "(": ")", + "[": "]", + "<": ">", + }[tok]; + if (next !== directClose && next !== "()" && next !== "[]" && next !== "<>") { + indentLevel++; + newLine(); + } + } + } + } + + // Close brackets + else if ([")", "}", "]", ">"].includes(tok)) { + // pop stack + const opening = stackTop(); + if ( + (tok === ")" && opening === "(") || + (tok === "]" && opening === "[") || + (tok === ">" && opening === "<") + ) { + // if not arrowParamSet or genericSet, multiline + if (!(arrowParamSet.has(i) && tok === ")") && !(genericSet.has(i) && tok === ">")) { + indentLevel = Math.max(indentLevel - 1, 0); + newLine(); + } + } else if (tok === "}" && opening === "{") { + indentLevel = Math.max(indentLevel - 1, 0); + newLine(); + } + stack.pop(); + placeToken(tok); + if (tok === "}") { + // break line after } + newLine(); + } + } + + // Combined empty pairs like '()', '[]', '{}', '<>' + else if (/^\(\)|\[\]|\{\}|\<\>$/.test(tok)) { + placeToken(tok); + + // Arrow => + } else if (tok === "=>") { + placeToken(tok); + // We'll let the next token (maybe '{') handle line breaks. + + // Semicolon + } else if (tok === ";") { + placeToken(tok, true); + newLine(); + + // Comma + } else if (tok === ",") { + placeToken(tok, true); + // If inside an arrow param set or generic set, don't break + // Otherwise, if top is {, (, [ or <, break line + const top = stackTop(); + if (!(arrowParamSet.has(i) && top === "(") && !(genericSet.has(i) && top === "<")) { + if (top && ["{", "[", "(", "<"].includes(top)) { + newLine(); + } + } + + // Everything else (identifiers, operators, colons, question marks, etc.) + } else { + placeToken(tok); + } + } + + pushLine(); + + // Remove extra blank lines + return lines + .join("\n") + .replace(/\n\s*\n+/g, "\n") + .trim(); +} + +// Update the formatFunctionPreview to use the new formatter +export const formatFunctionPreview = (fn: { toString(): string }, expanded = false): string => { + try { + const fnStr = fn.toString(); + const match = fnStr.match(/(?:function\s*)?(?:\(([^)]*)\)|([^=>\s]+))\s*=>?/); + if (!match) return "ƒ"; + + const params = match[1] || match[2] || ""; + const cleanParams = params.replace(/\s+/g, ""); + + if (!expanded) { + return `ƒ (${cleanParams}) => ...`; + } + + // For expanded view, use the new formatter + return hackyJsFormatter(fnStr); + } catch { + return "ƒ"; + } +}; + +export const formatValuePreview = (value: unknown): string => { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") + return `"${value.length > 150 ? `${value.slice(0, 20)}...` : value}"`; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "function") return formatFunctionPreview(value); + if (Array.isArray(value)) return `Array(${value.length})`; + if (value instanceof Map) return `Map(${value.size})`; + if (value instanceof Set) return `Set(${value.size})`; + if (value instanceof Date) return value.toISOString(); + if (value instanceof RegExp) return value.toString(); + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (typeof value === "object") { + const keys = Object.keys(value as object); + return `{${keys.length > 2 ? `${keys.slice(0, 2).join(", ")}, ...` : keys.join(", ")}}`; + } + return String(value); +}; + +export 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 interface TimelineSliderValues { + leftValue: number; + min: number; + max: number; + value: number; + rightValue: number; +} + +const calculateSliderValues = ( + totalUpdates: number, + currentIndex: number, +): TimelineSliderValues => { + if (totalUpdates <= TIMELINE_MAX_UPDATES) { + return { + leftValue: 0, + min: 0, + max: totalUpdates - 1, + value: currentIndex, + rightValue: totalUpdates - 1, + }; + } + + return { + leftValue: totalUpdates - TIMELINE_MAX_UPDATES, + min: 0, + max: TIMELINE_MAX_UPDATES - 1, + value: currentIndex, + rightValue: totalUpdates - 1, + }; +}; + +// be careful, this is an implementation detail is not stable or reliable across all react versions https://github.com/facebook/react/pull/15124 +// type UpdateQueue = { +// last: Update | null, +// dispatch: (A => mixed) | null, +// eagerReducer: ((S, A) => S) | null, +// eagerState: S | null, +// }; + +interface ExtendedMemoizedState extends MemoizedState { + queue?: { + lastRenderedState: unknown; + } | null; + element?: unknown; +} + +const isDirectComponent = (fiber: Fiber): boolean => { + if (!fiber || !fiber.type) return false; + + const isFunctionalComponent = typeof fiber.type === "function"; + const isClassComponent = fiber.type?.prototype?.isReactComponent ?? false; + + if (!(isFunctionalComponent || isClassComponent)) return false; + + if (isClassComponent) { + return true; + } + + let memoizedState = fiber.memoizedState; + while (memoizedState) { + if (memoizedState.queue) { + return true; + } + const nextState: ExtendedMemoizedState | null = memoizedState.next; + if (!nextState) break; + memoizedState = nextState; + } + + return false; +}; + +export const isPromise = (value: unknown): value is Promise => { + return !!value && (value instanceof Promise || (typeof value === "object" && "then" in value)); +}; + +const ensureRecord = ( + value: unknown, + maxDepth = 2, + seen = new WeakSet(), +): Record => { + if (isPromise(value)) { + return { type: "promise", displayValue: "Promise" }; + } + + if (value === null) { + return { type: "null", displayValue: "null" }; + } + + if (value === undefined) { + return { type: "undefined", displayValue: "undefined" }; + } + + switch (typeof value) { + case "object": { + if (seen.has(value)) { + return { type: "circular", displayValue: "[Circular Reference]" }; + } + + if (!value) return { type: "null", displayValue: "null" }; + + seen.add(value); + + try { + const result: Record = {}; + + if (value instanceof Element) { + result.type = "Element"; + result.tagName = value.tagName.toLowerCase(); + result.displayValue = value.tagName.toLowerCase(); + return result; + } + + if (value instanceof Map) { + result.type = "Map"; + result.size = value.size; + result.displayValue = `Map(${value.size})`; + + if (maxDepth > 0) { + const entries: Record = {}; + let index = 0; + for (const [key, val] of value.entries()) { + if (index >= 50) break; + try { + entries[String(key)] = ensureRecord(val, maxDepth - 1, seen); + } catch { + entries[String(index)] = { + type: "error", + displayValue: "Error accessing Map entry", + }; + } + index++; + } + result.entries = entries; + } + return result; + } + + if (value instanceof Set) { + result.type = "Set"; + result.size = value.size; + result.displayValue = `Set(${value.size})`; + + if (maxDepth > 0) { + const items = []; + let count = 0; + for (const item of value) { + if (count >= 50) break; + items.push(ensureRecord(item, maxDepth - 1, seen)); + count++; + } + result.items = items; + } + return result; + } + + if (value instanceof Date) { + result.type = "Date"; + result.value = value.toISOString(); + result.displayValue = value.toLocaleString(); + return result; + } + + if (value instanceof RegExp) { + result.type = "RegExp"; + result.value = value.toString(); + result.displayValue = value.toString(); + return result; + } + + if (value instanceof Error) { + result.type = "Error"; + result.name = value.name; + result.message = value.message; + result.displayValue = `${value.name}: ${value.message}`; + return result; + } + + if (value instanceof ArrayBuffer) { + result.type = "ArrayBuffer"; + result.byteLength = value.byteLength; + result.displayValue = `ArrayBuffer(${value.byteLength})`; + return result; + } + + if (value instanceof DataView) { + result.type = "DataView"; + result.byteLength = value.byteLength; + result.displayValue = `DataView(${value.byteLength})`; + return result; + } + + if (ArrayBuffer.isView(value)) { + const typedArray = value as unknown as { + length: number; + constructor: { name: string }; + buffer: ArrayBuffer; + }; + result.type = typedArray.constructor.name; + result.length = typedArray.length; + result.byteLength = typedArray.buffer.byteLength; + result.displayValue = `${typedArray.constructor.name}(${typedArray.length})`; + return result; + } + + if (Array.isArray(value)) { + result.type = "array"; + result.length = value.length; + result.displayValue = `Array(${value.length})`; + + if (maxDepth > 0) { + result.items = value.slice(0, 50).map((item) => ensureRecord(item, maxDepth - 1, seen)); + } + return result; + } + + const keys = Object.keys(value); + result.type = "object"; + result.size = keys.length; + result.displayValue = + keys.length <= 5 + ? `{${keys.join(", ")}}` + : `{${keys.slice(0, 5).join(", ")}, ...${keys.length - 5}}`; + + if (maxDepth > 0) { + const entries: Record = {}; + for (const key of keys.slice(0, 50)) { + try { + entries[key] = ensureRecord( + (value as Record)[key], + maxDepth - 1, + seen, + ); + } catch { + entries[key] = { + type: "error", + displayValue: "Error accessing property", + }; + } + } + result.entries = entries; + } + return result; + } finally { + seen.delete(value); + } + } + case "string": + return { + type: "string", + value, + displayValue: `"${value}"`, + }; + case "function": + return { + type: "function", + displayValue: "ƒ()", + name: value.name || "anonymous", + }; + default: + return { + type: typeof value, + value, + displayValue: String(value), + }; + } +}; + +const getCurrentFiberState = (fiber: Fiber): Record | null => { + if (fiber.tag !== FunctionComponentTag || !isDirectComponent(fiber)) { + return null; + } + + const currentIsNewer = fiber.alternate + ? (fiber.actualStartTime ?? 0) > (fiber.alternate.actualStartTime ?? 0) + : true; + + const memoizedState: ExtendedMemoizedState | null = currentIsNewer + ? fiber.memoizedState + : (fiber.alternate?.memoizedState ?? fiber.memoizedState); + + if (!memoizedState) return null; + + return memoizedState; +}; + +const replayComponent = async (fiber: Fiber): Promise => { + const { overrideProps, overrideHookState, overrideContext } = getOverrideMethods(); + if (!overrideProps || !overrideHookState || !fiber) return; + + try { + // Handle props updates + const currentProps = fiber.memoizedProps || {}; + const propKeys = Object.keys(currentProps).filter((key) => { + const value = currentProps[key]; + if (Array.isArray(value) || typeof value === "string") { + return !Number.isInteger(Number(key)) && key !== "length"; + } + return true; + }); + + for (const key of propKeys) { + try { + const value = currentProps[key]; + // For arrays and objects, we need to clone to trigger updates + const propValue = Array.isArray(value) + ? [...value] + : typeof value === "object" && value !== null + ? { ...value } + : value; + overrideProps(fiber, [key], propValue); + } catch {} + } + + // Handle state updates + const currentState = getCurrentFiberState(fiber); + if (currentState) { + const stateNames = getStateNames(fiber); + + // First, handle named state hooks + for (const [key, value] of Object.entries(currentState)) { + try { + const namedStateIndex = stateNames.indexOf(key); + if (namedStateIndex !== -1) { + const hookId = namedStateIndex.toString(); + // For arrays and objects, we need to clone to trigger updates + const stateValue = Array.isArray(value) + ? [...value] + : typeof value === "object" && value !== null + ? { ...value } + : value; + overrideHookState(fiber, hookId, [], stateValue); + } + } catch {} + } + + // Then handle unnamed state hooks + let hookIndex = 0; + let currentHook = fiber.memoizedState; + while (currentHook !== null) { + try { + const hookId = hookIndex.toString(); + const value = currentHook.memoizedState; + + // Only update if this hook isn't already handled by named states + if (!stateNames.includes(hookId)) { + // For arrays and objects, we need to clone to trigger updates + const stateValue = Array.isArray(value) + ? [...value] + : typeof value === "object" && value !== null + ? { ...value } + : value; + overrideHookState(fiber, hookId, [], stateValue); + } + } catch {} + + currentHook = currentHook.next as typeof currentHook; + hookIndex++; + } + } + + // Handle context updates + if (overrideContext) { + const contexts = getAllFiberContexts(fiber); + if (contexts) { + for (const [contextType, ctx] of contexts) { + try { + // Find the provider fiber for this context + let current: Fiber | null = fiber; + while (current) { + const type = current.type as { Provider?: unknown }; + if (type === contextType || type?.Provider === contextType) { + // Get the value we want to update to + const newValue = ctx.value; + if (newValue === undefined || newValue === null) break; + + // Only update if the value has actually changed + const currentValue = current.memoizedProps?.value; + if (isEqual(currentValue, newValue)) break; + + // Update the provider's value prop + overrideProps(current, ["value"], newValue); + if (current.alternate) { + overrideProps(current.alternate, ["value"], newValue); + } + break; + } + current = current.return; + } + } catch {} + } + } + } + + // Recursively handle children + let child = fiber.child; + while (child) { + await replayComponent(child); + child = child.sibling; + } + } catch {} +}; + +export const extractMinimalFiberInfo = (fiber: Fiber): MinimalFiberInfo => { + const timings = getTimings(fiber); + return { + displayName: getDisplayName(fiber) || "Unknown", + type: fiber.type, + key: fiber.key, + id: fiber.index, + selfTime: timings?.selfTime ?? null, + totalTime: timings?.totalTime ?? null, + }; +}; diff --git a/packages/scan-react/src/web/views/inspector/what-changed.tsx b/packages/scan-react/src/web/views/inspector/what-changed.tsx new file mode 100644 index 00000000..cb48e559 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/what-changed.tsx @@ -0,0 +1,689 @@ +import { type ReactNode, memo } from "react"; +import { useEffect, useRef, useState } from "react"; +import { CopyToClipboard } from "~web/components/copy-to-clipboard"; +import { Icon } from "~web/components/icon"; +import { cn, throttle } from "~web/utils/helpers"; +import { useSignalValue } from "~web/utils/signals"; +import { DiffValueView } from "./diff-value"; +import { timelineState } from "./states"; +import { + AggregatedChanges, + formatFunctionPreview, + formatPath, + getObjectDiff, + safeGetValue, +} from "./utils"; +import { + calculateTotalChanges, + useInspectedFiberChangeStore, +} from "./whats-changed/use-change-store"; +import { getDisplayName, getType } from "bippy"; +import { Store } from "~core/index"; + +export const WhatChanged = /* @__PURE__ */ memo(() => { + const [isExpanded, setIsExpanded] = useState(true); + const aggregatedChanges = useInspectedFiberChangeStore(); + + const [hasInitialized, setHasInitialized] = useState(false); + const hasAnyChanges = calculateTotalChanges(aggregatedChanges) > 0; + useEffect(() => { + if (!hasInitialized && hasAnyChanges) { + const timer = setTimeout(() => { + setHasInitialized(true); + requestAnimationFrame(() => { + setIsExpanded(true); + }); + }, 0); + return () => clearTimeout(timer); + } + }, [hasInitialized, hasAnyChanges]); + + const initializedContextChanges = new Map( + Array.from(aggregatedChanges.contextChanges.entries()) + .filter(([, value]) => value.kind === "initialized") + .map(([key, value]) => [ + key, + // oxlint-disable-next-line typescript/no-non-null-assertion + value.kind === "partially-initialized" ? null! : value.changes, + ]), + ); + + const inspectState = useSignalValue(Store.inspectState); + const fiber = inspectState.kind === "focused" ? inspectState.fiber : null; + + if (!fiber) { + // invariant + return; + } + return ( + <> + + +
+
+ + Why did {getDisplayName(fiber)} render? + + {!hasAnyChanges && ( +
+
No changes detected since selecting
+
+ The props, state, and context changes within your component will be reported here +
+
+ )} +
+
+
+
+ renderStateName(name, getDisplayName(getType(fiber)) ?? "Unknown Component") + } + changes={aggregatedChanges.stateChanges} + title="Changed State" + isExpanded={isExpanded} + /> +
+
+
+ + ); +}); + +const renderStateName = (key: string, componentName: string) => { + if (Number.isNaN(Number(key))) { + return key; + } + + const n = Number.parseInt(key); + const getOrdinalSuffix = (num: number) => { + const lastDigit = num % 10; + const lastTwoDigits = num % 100; + if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { + return "th"; + } + switch (lastDigit) { + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; + } + }; + + return ( + + + {n} + {getOrdinalSuffix(n)} hook{" "} + + + called in {componentName} + + + ); +}; + +const WhatsChangedHeader = memo(() => { + const refProps = useRef(null); + const refState = useRef(null); + const refContext = useRef(null); + + const refStats = useRef<{ + isPropsChanged: boolean; + isStateChanged: boolean; + isContextChanged: boolean; + }>({ + isPropsChanged: false, + isStateChanged: false, + isContextChanged: false, + }); + + useEffect(() => { + const flash = throttle(() => { + const flashElements = []; + if (refProps.current?.dataset.flash === "true") { + flashElements.push(refProps.current); + } + if (refState.current?.dataset.flash === "true") { + flashElements.push(refState.current); + } + if (refContext.current?.dataset.flash === "true") { + flashElements.push(refContext.current); + } + + for (const element of flashElements) { + element.classList.remove("count-flash-white"); + void element.offsetWidth; + element.classList.add("count-flash-white"); + } + }, 400); + + const unsubscribe = timelineState.subscribe((state) => { + if (!refProps.current || !refState.current || !refContext.current) { + return; + } + + const { currentIndex, updates } = state; + const currentUpdate = updates[currentIndex]; + + if (!currentUpdate || currentIndex === 0) { + return; + } + + flash(); + + refStats.current = { + isPropsChanged: (currentUpdate.props?.changes?.size ?? 0) > 0, + isStateChanged: (currentUpdate.state?.changes?.size ?? 0) > 0, + isContextChanged: (currentUpdate.context?.changes?.size ?? 0) > 0, + }; + + if (refProps.current.dataset.flash !== "true") { + refProps.current.dataset.flash = refStats.current.isPropsChanged.toString(); + } + if (refState.current.dataset.flash !== "true") { + refState.current.dataset.flash = refStats.current.isStateChanged.toString(); + } + if (refContext.current.dataset.flash !== "true") { + refContext.current.dataset.flash = refStats.current.isContextChanged.toString(); + } + }); + + return unsubscribe; + }, []); + + return ( + + ); +}); + +interface SectionProps { + title: string; + isExpanded: boolean; + // oxlint-disable-next-line typescript/no-explicit-any + changes: Map; + renderName?: (name: string) => ReactNode; +} +const identity = (x: T) => x; +const Section = /* @__PURE__ */ memo(({ title, changes, renderName = identity }: SectionProps) => { + const [expandedFns, setExpandedFns] = useState(new Set()); + const [expandedEntries, setExpandedEntries] = useState(new Set()); + + const entries = Array.from(changes.entries()); + + if (changes.size === 0) { + return null; + } + return ( +
+
{title}
+
+ {entries.map(([entryKey, change]) => { + const isEntryExpanded = expandedEntries.has(String(entryKey)); + const { value: prevValue, error: prevError } = safeGetValue(change.previousValue); + const { value: currValue, error: currError } = safeGetValue(change.currentValue); + + const diff = getObjectDiff(prevValue, currValue); + + return ( +
+ +
+
+
+ {prevError || currError ? ( + + ) : diff.changes.length > 0 ? ( + + ) : ( + + )} +
+
+
+
+ ); + })} +
+
+ ); +}); + +const AccessError = ({ prevError, currError }: { prevError?: string; currError?: string }) => { + return ( + <> + {prevError && ( +
+ {prevError} +
+ )} + {currError && ( +
+ {currError} +
+ )} + + ); +}; + +const DiffChange = ({ + diff, + title, + renderName, + change, + expandedFns, + setExpandedFns, +}: { + diff: { + changes: { + path: string[]; + prevValue: unknown; + currentValue: unknown; + }[]; + }; + title: string; + renderName: (name: string) => ReactNode; + change: { name: string }; + expandedFns: Set; + setExpandedFns: (updater: (prev: Set) => Set) => void; +}) => { + return diff.changes.map((diffChange, i) => { + const { value: prevDiffValue, error: prevDiffError } = safeGetValue(diffChange.prevValue); + const { value: currDiffValue, error: currDiffError } = safeGetValue(diffChange.currentValue); + + const isFunction = typeof prevDiffValue === "function" || typeof currDiffValue === "function"; + + let path: string | undefined; + + if (title === "Props") { + path = + diffChange.path.length > 0 + ? `${renderName(String(change.name))}.${formatPath(diffChange.path)}` + : undefined; + } + if (title === "State" && diffChange.path.length > 0) { + path = `state.${formatPath(diffChange.path)}`; + } + + if (!path) { + path = formatPath(diffChange.path); + } + + return ( +
+ {path &&
{path}
} + + +
+ ); + }); +}; + +const ReferenceOnlyChange = ({ + prevValue, + currValue, + entryKey, + expandedFns, + setExpandedFns, +}: { + prevValue: unknown; + currValue: unknown; + entryKey: string | number; + expandedFns: Set; + setExpandedFns: (updater: (prev: Set) => Set) => void; +}) => { + return ( + <> +
+ - + + { + const key = `${String(entryKey)}-prev`; + setExpandedFns((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }} + isNegative={true} + /> + +
+
+ + + + { + const key = `${String(entryKey)}-current`; + setExpandedFns((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }} + isNegative={false} + /> + +
+ {typeof currValue === "object" && currValue !== null && ( +
+ + Reference changed but objects are structurally the same +
+ )} + + ); +}; + +const CountBadge = ({ + count, + forceFlash, + isFunction, + showWarning, +}: { + count: number; + forceFlash: boolean; + isFunction: boolean; + showWarning: boolean; +}) => { + const refIsFirstRender = useRef(true); + const refBadge = useRef(null); + const refPrevCount = useRef(count); + + useEffect(() => { + const element = refBadge.current; + if (!element || refPrevCount.current === count) { + return; + } + + element.classList.remove("count-flash"); + void element.offsetWidth; + element.classList.add("count-flash"); + + refPrevCount.current = count; + }, [count]); + + useEffect(() => { + if (refIsFirstRender.current) { + refIsFirstRender.current = false; + return; + } + + if (forceFlash) { + let timer = setTimeout(() => { + refBadge.current?.classList.add("count-flash-white"); + timer = setTimeout(() => { + refBadge.current?.classList.remove("count-flash-white"); + }, 300); + }, 500); + return () => { + clearTimeout(timer); + }; + } + }, [forceFlash]); + + return ( +
+ {showWarning && ( + + )} + {isFunction && }x + {count} +
+ ); +}; diff --git a/packages/scan-react/src/web/views/inspector/whats-changed/use-change-store.ts b/packages/scan-react/src/web/views/inspector/whats-changed/use-change-store.ts new file mode 100644 index 00000000..e51fc733 --- /dev/null +++ b/packages/scan-react/src/web/views/inspector/whats-changed/use-change-store.ts @@ -0,0 +1,395 @@ +import { useEffect, useRef, useState } from "react"; +import { ChangesListener, ChangesPayload, ContextChange, Store } from "~core/index"; +import { getFiberId } from "bippy"; +import { isEqual } from "~core/utils"; + +const CHANGES_QUEUE_INTERVAL = 50; + +export type AggregatedChanges = { + count: number; + currentValue: unknown; + previousValue: unknown; + name: string; + lastUpdated: number; + id: string; +}; + +export type AllAggregatedChanges = { + // oxlint-disable-next-line typescript/no-explicit-any + propsChanges: Map; + // oxlint-disable-next-line typescript/no-explicit-any + stateChanges: Map; + contextChanges: Map< + // oxlint-disable-next-line typescript/no-explicit-any + any, + | { changes: AggregatedChanges; kind: "initialized" } + | { + // this looks weird, because it is + // its a work around to allow context changes to be sent impotently + // (react-scan internals do not yet handle sending context changes the render they change) + kind: "partially-initialized"; + value: unknown; + name: string; + lastUpdated: number; + id: string; + } + >; +}; + +const getContextChangesValue = ( + discriminated: + | { kind: "partially-initialized"; value: unknown } + | { kind: "initialized"; changes: AggregatedChanges }, +) => { + switch (discriminated.kind) { + case "initialized": { + return discriminated.changes.currentValue; + } + case "partially-initialized": { + return discriminated.value; + } + } +}; +const processChanges = ( + changes: Array<{ name: string; value: unknown; prevValue?: unknown }>, + // oxlint-disable-next-line typescript/no-explicit-any + targetMap: Map, +) => { + for (const change of changes) { + const existing = targetMap.get(change.name); + + if (existing) { + targetMap.set(existing.name, { + count: existing.count + 1, + currentValue: change.value, + id: existing.name, + lastUpdated: Date.now(), + name: existing.name, + previousValue: change.prevValue, + }); + continue; + } + + targetMap.set(change.name, { + count: 1, + currentValue: change.value, + id: change.name, + lastUpdated: Date.now(), + name: change.name, + previousValue: change.prevValue, + }); + } +}; + +const processContextChanges = ( + contextChanges: Array, + aggregatedChanges: AllAggregatedChanges, +) => { + for (const change of contextChanges) { + const existing = aggregatedChanges.contextChanges.get(change.contextType); + + if (existing) { + if (isEqual(getContextChangesValue(existing), change.value)) { + continue; + } + if (existing.kind === "partially-initialized") { + aggregatedChanges.contextChanges.set(change.contextType, { + kind: "initialized", + changes: { + count: 1, + currentValue: change.value, + id: change.contextType.toString(), // come back to this why was this ever expected to be a number? + lastUpdated: Date.now(), + name: change.name, + previousValue: existing.value, + }, + }); + continue; + } + + aggregatedChanges.contextChanges.set(change.contextType, { + kind: "initialized", + changes: { + count: existing.changes.count + 1, + currentValue: change.value, + id: change.contextType.toString(), + lastUpdated: Date.now(), + name: change.name, + previousValue: existing.changes.currentValue, + }, + }); + + continue; + } + + aggregatedChanges.contextChanges.set(change.contextType, { + kind: "partially-initialized", + id: change.contextType.toString(), + lastUpdated: Date.now(), + name: change.name, + value: change.value, + }); + } +}; + +const collapseQueue = (queue: Array) => { + const localAggregatedChanges: AllAggregatedChanges = { + contextChanges: new Map(), + propsChanges: new Map(), + stateChanges: new Map(), + }; + + queue.forEach((changes) => { + // context is a special case since we don't send precise diffs and need to be idempotent + processContextChanges(changes.contextChanges, localAggregatedChanges); + + processChanges(changes.stateChanges, localAggregatedChanges.stateChanges); + + processChanges(changes.propsChanges, localAggregatedChanges.propsChanges); + }); + + return localAggregatedChanges; +}; +const mergeSimpleChanges = < + T extends AllAggregatedChanges["propsChanges"] | AllAggregatedChanges["stateChanges"], +>( + existingChanges: T, + incomingChanges: T, +): T => { + const mergedChanges = new Map(); + + existingChanges.forEach((value, key) => { + mergedChanges.set(key, value); + }); + + incomingChanges.forEach((incomingChange, key) => { + const existing = mergedChanges.get(key); + + if (!existing) { + mergedChanges.set(key, incomingChange); + return; + } + + mergedChanges.set(key, { + count: existing.count + incomingChange.count, + currentValue: incomingChange.currentValue, + id: incomingChange.id, + lastUpdated: incomingChange.lastUpdated, + name: incomingChange.name, + previousValue: incomingChange.previousValue, + }); + }); + + return mergedChanges as T; +}; + +const mergeContextChanges = (existing: AllAggregatedChanges, incoming: AllAggregatedChanges) => { + const contextChanges: AllAggregatedChanges["contextChanges"] = new Map(); + + existing.contextChanges.forEach((value, key) => { + contextChanges.set(key, value); + }); + + incoming.contextChanges.forEach((incomingChange, key) => { + const existingChange = contextChanges.get(key); + + if (!existingChange) { + contextChanges.set(key, incomingChange); + return; + } + if (getContextChangesValue(incomingChange) === getContextChangesValue(existingChange)) { + // we do this for a second time just in context merge to handle the partial initialization case (the collapsed queue will not have the information to remove the partially initialized set of changes) + return; + } + + switch (existingChange.kind) { + case "initialized": { + switch (incomingChange.kind) { + case "initialized": { + const preInitEntryOffset = 1; + contextChanges.set(key, { + kind: "initialized", + changes: { + ...incomingChange.changes, + // if existing was initialized, the pre-initialization done by the collapsed queue was not necessary, so we need to increment count to account for the preInit entry + count: + incomingChange.changes.count + existingChange.changes.count + preInitEntryOffset, + currentValue: incomingChange.changes.currentValue, + + previousValue: incomingChange.changes.previousValue, // we always want to show this value, since this will be the true state transition (if you make the previousValue the last seen currentValue, u will have weird behavior with primitive state updates) + }, + }); + return; + } + case "partially-initialized": { + contextChanges.set(key, { + kind: "initialized", + changes: { + count: existingChange.changes.count + 1, + currentValue: incomingChange.value, + id: incomingChange.id, + lastUpdated: incomingChange.lastUpdated, + name: incomingChange.name, + previousValue: existingChange.changes.currentValue, + }, + }); + return; + } + } + } + case "partially-initialized": { + switch (incomingChange.kind) { + case "initialized": { + contextChanges.set(key, { + kind: "initialized", + changes: { + count: incomingChange.changes.count + 1, + currentValue: incomingChange.changes.currentValue, + id: incomingChange.changes.id, + lastUpdated: incomingChange.changes.lastUpdated, + name: incomingChange.changes.name, + previousValue: existingChange.value, + }, + }); + return; + } + case "partially-initialized": { + contextChanges.set(key, { + kind: "initialized", + changes: { + count: 1, + currentValue: incomingChange.value, + id: incomingChange.id, + lastUpdated: incomingChange.lastUpdated, + name: incomingChange.name, + previousValue: existingChange.value, + }, + }); + return; + } + } + } + } + }); + + return contextChanges; +}; + +const mergeChanges = ( + existing: AllAggregatedChanges, + incoming: AllAggregatedChanges, +): AllAggregatedChanges => { + const contextChanges = mergeContextChanges(existing, incoming); + + const propChanges = mergeSimpleChanges(existing.propsChanges, incoming.propsChanges); + const stateChanges = mergeSimpleChanges(existing.stateChanges, incoming.stateChanges); + + return { + contextChanges, + propsChanges: propChanges, + stateChanges, + }; +}; + +/** + * Calculate total count of changes across props, state and context + */ +export const calculateTotalChanges = (changes: AllAggregatedChanges) => { + return ( + Array.from(changes.propsChanges.values()).reduce((acc, change) => acc + change.count, 0) + + Array.from(changes.stateChanges.values()).reduce((acc, change) => acc + change.count, 0) + + Array.from(changes.contextChanges.values()) + .filter( + (change): change is Extract => + change.kind === "initialized", + ) + .reduce((acc, change) => acc + change.changes.count, 0) + ); +}; + +export const useInspectedFiberChangeStore = (opts?: { + onChangeUpdate?: (countUpdated: number) => void; +}) => { + const pendingChanges = useRef<{ queue: ChangesPayload[] }>({ queue: [] }); + // flushed state read from queue stream + const [aggregatedChanges, setAggregatedChanges] = useState({ + propsChanges: new Map(), + stateChanges: new Map(), + contextChanges: new Map(), + }); + + const fiber = Store.inspectState.value.kind === "focused" ? Store.inspectState.value.fiber : null; + const fiberId = fiber ? getFiberId(fiber) : null; + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const interval = setInterval(() => { + // optimization to avoid unconditional renders + if (pendingChanges.current.queue.length === 0) return; + + setAggregatedChanges((prevAggregatedChanges) => { + const queueChanges = collapseQueue(pendingChanges.current.queue); + const merged = mergeChanges(prevAggregatedChanges, queueChanges); + const prevTotal = calculateTotalChanges(prevAggregatedChanges); + const newTotal = calculateTotalChanges(merged); + const changeCount = newTotal - prevTotal; + opts?.onChangeUpdate?.(changeCount); + + return merged; + }); + + pendingChanges.current.queue = []; + }, CHANGES_QUEUE_INTERVAL); + + return () => { + clearInterval(interval); + }; + }, [fiber]); + + // un-throttled subscription + useEffect(() => { + if (!fiberId) { + return; + } + const listener: ChangesListener = (change) => { + pendingChanges.current?.queue.push(change); + }; + + let listeners = Store.changesListeners.get(fiberId); + + if (!listeners) { + listeners = []; + Store.changesListeners.set(fiberId, listeners); + } + + listeners.push(listener); + + return () => { + setAggregatedChanges({ + propsChanges: new Map(), + stateChanges: new Map(), + contextChanges: new Map(), + }); + pendingChanges.current.queue = []; + Store.changesListeners.set( + fiberId, + Store.changesListeners.get(fiberId)?.filter((l) => l !== listener) ?? [], + ); + }; + }, [fiberId]); + + // cleanup + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + return () => { + setAggregatedChanges({ + propsChanges: new Map(), + stateChanges: new Map(), + contextChanges: new Map(), + }); + pendingChanges.current.queue = []; + }; + }, [fiberId]); + + return aggregatedChanges; +}; diff --git a/packages/scan-react/src/web/views/notifications/collapsed-event.tsx b/packages/scan-react/src/web/views/notifications/collapsed-event.tsx new file mode 100644 index 00000000..91e20dbc --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/collapsed-event.tsx @@ -0,0 +1,184 @@ +import type { JSX } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { + DroppedFramesEvent, + getComponentName, + getEventSeverity, + InteractionEvent, +} from './data'; +import { SlowdownHistoryItem } from './slowdown-history'; +import { ChevronRight } from './icons'; +import { cn } from '~web/utils/helpers'; + +export type CollapsedDroppedFrame = { + kind: 'collapsed-frame-drops'; + events: Array; + timestamp: number; +}; + +type CollapsedKeyboardInput = { + kind: 'collapsed-keyboard'; + events: Array; + timestamp: number; +}; +const useNestedFlash = ({ + flashingItemsCount, + totalEvents, +}: { + totalEvents: number; // this breaks if you have constant 1 item flashing, but the actual item is different over time (it's fine for now) + flashingItemsCount: number; +}) => { + const [newFlash, setNewFlash] = useState(false); + const flashedFor = useRef(0); + const lastFlashTime = useRef(0); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (flashedFor.current >= totalEvents) { + return; + } + + const now = Date.now(); + const debounceTime = 250; + const timeSinceLastFlash = now - lastFlashTime.current; + + if (timeSinceLastFlash >= debounceTime) { + setNewFlash(false); + const timeout = setTimeout(() => { + flashedFor.current = totalEvents; + lastFlashTime.current = Date.now(); + setNewFlash(true); + // horrible, don't look at this, move along + setTimeout(() => { + setNewFlash(false); + }, 2000); + }, 50); + return () => clearTimeout(timeout); + } else { + const delayNeeded = debounceTime - timeSinceLastFlash; + const timeout = setTimeout(() => { + setNewFlash(false); + setTimeout(() => { + flashedFor.current = totalEvents; + lastFlashTime.current = Date.now(); + setNewFlash(true); + // horrible, don't look at this, move along + setTimeout(() => { + setNewFlash(false); + }, 2000); + }, 50); + }, delayNeeded); + return () => clearTimeout(timeout); + } + }, [flashingItemsCount]); + + return newFlash; +}; + +export const CollapsedItem = ({ + item, + shouldFlash, +}: { + item: CollapsedDroppedFrame | CollapsedKeyboardInput; + shouldFlash: (id: string) => boolean; +}) => { + const [expanded, setExpanded] = useState(false); + + const severity = item.events.map(getEventSeverity).reduce((prev, curr) => { + switch (curr) { + case 'high': { + return 'high'; + } + case 'needs-improvement': { + return prev === 'high' ? 'high' : 'needs-improvement'; + } + case 'low': { + return prev; + } + } + }, 'low'); + const flashingItemsCount = item.events.reduce( + (prev, curr) => (shouldFlash(curr.id) ? prev + 1 : prev), + 0, + ); + + const shouldFlashAgain = useNestedFlash({ + flashingItemsCount, + totalEvents: item.events.length, + }); + + return ( +
+ + {expanded && ( + + {item.events + .toSorted((a, b) => b.timestamp - a.timestamp) + .map((event) => ( + + ))} + + )} +
+ ); +}; + +const IndentedContent = ({ + children, +}: { children: JSX.Element | JSX.Element[] }) => ( +
+
+ {children} +
+); diff --git a/packages/scan-react/src/web/views/notifications/data.ts b/packages/scan-react/src/web/views/notifications/data.ts new file mode 100644 index 00000000..19ed74aa --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/data.ts @@ -0,0 +1,164 @@ +import { createContext, useContext } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { HIGH_SEVERITY_FPS_DROP_TIME } from "~core/notifications/event-tracking"; + +export type GroupedFiberRender = { + id: string; + name: string; + count: number; + changes: { + props: Array<{ name: string; count: number }>; + state: Array<{ index: number; count: number }>; + context: Array<{ name: string; count: number }>; + }; + // fixme: incorrect assumption, make this nullable + /** Not available when running in production, but we will not render notifications in production */ + totalTime: number; + elements: Array; // can't do a weak set because need to iterate over them...... + deletedAll: boolean; + parents: Set; + hasMemoCache: boolean; + wasFiberRenderMount: boolean; +}; +export const getComponentName = (path: Array) => { + const filteredPath = path.filter((item) => item.length > 2); + // in production, all names can be minified + if (filteredPath.length === 0) { + return path.at(-1) ?? "Unknown"; + } + // oxlint-disable-next-line typescript/no-non-null-assertion + return filteredPath.at(-1)!; +}; + +export const getTotalTime = (timing: InteractionTiming | DroppedFramesTiming) => { + switch (timing.kind) { + case "interaction": { + const { renderTime, otherJSTime, framePreparation, frameConstruction, frameDraw } = timing; + return renderTime + otherJSTime + framePreparation + frameConstruction + (frameDraw ?? 0); + } + case "dropped-frames": { + return timing.otherTime + timing.renderTime; + } + } +}; + +export type DroppedFramesTiming = { + kind: "dropped-frames"; + renderTime: number; + otherTime: number; +}; +export type InteractionTiming = { + kind: "interaction"; + renderTime: number; + otherJSTime: number; + /** After JS, before paint. Things like layerize, css style recalcs */ + framePreparation: number; + /** paint/commit. This is where the browser constructs the data structure that represents what will be drawn to screen */ + frameConstruction: number; + /** GPU/compositing/rasterization. This is where, off the main thread, the data structure built is used to draw the next frame. This value is not available on safari due to lack of PerformanceEntry API */ + frameDraw: number | null; +}; + +export const isRenderMemoizable = (groupedFiberRender: GroupedFiberRender): boolean => { + if (groupedFiberRender.wasFiberRenderMount) { + // no amount of memoization can prevent a mount render + return false; + } + // this shouldn't be needed, it implies we either are tracking renders wrong, are tracking changes wrong, or are not tracking some other "state" that can cause re-renders, but its a better fallback than failing + if (groupedFiberRender.hasMemoCache) { + return false; + } + + return ( + groupedFiberRender.changes.context.length === 0 && + groupedFiberRender.changes.props.length === 0 && + groupedFiberRender.changes.state.length === 0 + ); +}; + +export type InteractionEvent = { + kind: "interaction"; + type: "click" | "keyboard"; + id: string; + componentPath: Array; + groupedFiberRenders: Array; + timing: InteractionTiming; + /** Not available in safari, and API used to get value is not stable on chrome */ + memory: number | null; + timestamp: number; +}; +export type DroppedFramesEvent = { + kind: "dropped-frames"; + id: string; + groupedFiberRenders: Array; + timing: DroppedFramesTiming; + /** Not available in safari, and API used to get value is not stable on chrome */ + memory: number | null; + timestamp: number; + fps: number; +}; +export type NotificationEvent = InteractionEvent | DroppedFramesEvent; + +export type NotificationsState = { + events: Array; + // todo: discriminated union this all, i don't want to do it yet till i stabilize the data i need/ implement it all + selectedEvent: NotificationEvent | null; + filterBy: "severity" | "latest"; + selectedFiber: NotificationEvent["groupedFiberRenders"][number] | null; + detailsExpanded: boolean; + moreInfoExpanded: boolean; + route: + | "render-visualization" + | "other-visualization" + // | "render-guide" + | "render-explanation" + // | "other-guide" + | "optimize"; + /** + * Conceptually a synthetic query parameter + */ + routeMessage: null | { + kind: "auto-open-overview-accordion"; + name: "other-not-javascript" | "other-javascript" | "render" | "other-frame-drop"; + }; + audioNotificationsOptions: + | { + audioContext: null; + enabled: false; + } + | { + enabled: true; + audioContext: AudioContext; + }; +}; + +export const getEventSeverity = (event: NotificationEvent) => { + const totalTime = getTotalTime(event.timing); + switch (event.kind) { + case "interaction": { + if (totalTime < 200) return "low"; + if (totalTime < 500) return "needs-improvement"; + return "high"; + } + case "dropped-frames": { + if (totalTime < 50) return "low"; + if (totalTime < HIGH_SEVERITY_FPS_DROP_TIME) return "needs-improvement"; + return "high"; + } + } +}; + +export const useNotificationsContext = () => useContext(NotificationStateContext); + +export const NotificationStateContext = createContext<{ + notificationState: NotificationsState; + setNotificationState: Dispatch>; + setRoute: ({ + route, + routeMessage, + }: { + route: NotificationsState["route"]; + routeMessage: NotificationsState["routeMessage"] | null; + }) => void; + // oxlint-disable-next-line typescript/no-non-null-assertion +}>(null!); diff --git a/packages/scan-react/src/web/views/notifications/details-routes.tsx b/packages/scan-react/src/web/views/notifications/details-routes.tsx new file mode 100644 index 00000000..23bf3393 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/details-routes.tsx @@ -0,0 +1,208 @@ +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { playNotificationSound } from '~core/utils'; +import { cn } from '~web/utils/helpers'; +import { useNotificationsContext } from './data'; +import { CloseIcon } from './icons'; +import { NotificationTabs } from './notification-tabs'; +import { Optimize } from './optimize'; +import { OtherVisualization } from './other-visualization'; +import { RenderBarChart } from './render-bar-chart'; +import { RenderExplanation } from './render-explanation'; +import { signalWidgetViews } from '~web/state'; + +export const DetailsRoutes = () => { + const { notificationState, setNotificationState } = useNotificationsContext(); + const [dots, setDots] = useState('...'); + const containerRef = useRef(null); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => { + if (prev === '...') return ''; + return prev + '.'; + }); + }, 500); + + return () => clearInterval(interval); + }, []); + + if (!notificationState.selectedEvent) { + return ( +
+
+ +
+
+
+
+ + Scanning for slowdowns + {dots} + +
+ {notificationState.events.length !== 0 && ( +

+ Click on an item in the{' '} + History list to + get started +

+ )} +

+ You don't need to keep this panel open for React Scan to record + slowdowns +

+

+ Enable audio alerts to hear a delightful ding every time a large + slowdown is recorded +

+ +
+
+
+ ); + } + + switch (notificationState.route) { + case 'render-visualization': { + return ( + + + + ); + } + case 'render-explanation': { + if (!notificationState.selectedFiber) { + // todo: dev only + throw new Error( + 'Invariant: must have selected fiber when viewing render explanation', + ); + } + return ( + + + + ); + } + + case 'other-visualization': { + return ( + +
+ +
+
+ ); + } + case 'optimize': { + return ( + + + + ); + } + } + // exhaustive verification + notificationState.route satisfies never; +}; + +const TabLayout = ({ children }: { children: ReactNode }) => { + const { notificationState } = useNotificationsContext(); + if (!notificationState.selectedEvent) { + // todo: dev only + throw new Error( + 'Invariant: d must have selected event when viewing render explanation', + ); + } + return ( +
+
+ +
+
+ {children} +
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/icons.tsx b/packages/scan-react/src/web/views/notifications/icons.tsx new file mode 100644 index 00000000..2b78a3f2 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/icons.tsx @@ -0,0 +1,264 @@ +import { ReactScanInternals } from "~core/index"; +import { cn } from "~web/utils/helpers"; + +export const ChevronRight = ({ size = 24, className }: { size?: number; className?: string }) => ( + + + +); + +export const Notification = ({ + className = "", + size = 24, + events = [], +}: { + className?: string; + size?: number; + events: boolean[]; +}) => { + const hasHighSeverity = events.includes(true); + const totalSevere = events.filter((e) => e).length; + const displayCount = totalSevere > 99 ? ">99" : totalSevere; + const badgeSize = hasHighSeverity ? Math.max(size * 0.6, 14) : Math.max(size * 0.4, 6); + + return ( +
+ + + + + {events.length > 0 && + totalSevere > 0 && + ReactScanInternals.options.value.showNotificationCount && ( +
+ {hasHighSeverity && displayCount} +
+ )} +
+ ); +}; + +export const CloseIcon = ({ className = "", size = 24 }: { className?: string; size?: number }) => ( + + + + +); +export const VolumeOnIcon = ({ + className = "", + size = 24, +}: { + className?: string; + size?: number; +}) => ( + + + + + +); + +export const VolumeOffIcon = ({ + className = "", + size = 24, +}: { + className?: string; + size?: number; +}) => ( + + + + + + + +); + +export const ArrowLeft = ({ size = 24, className }: { size?: number; className?: string }) => ( + + + + +); + +export const PointerIcon = ({ + className = "", + size = 24, +}: { + className?: string; + size?: number; +}) => ( + + + + + + + +); + +export const KeyboardIcon = ({ + className = "", + size = 24, +}: { + className?: string; + size?: number; +}) => ( + + + + + + + + + + + +); +export const ClearIcon = ({ className = "", size = 24 }: { className?: string; size?: number }) => { + return ( + + + + + ); +}; +export const TrendingDownIcon = ({ + className = "", + size = 24, +}: { + className?: string; + size?: number; +}) => ( + + + + +); diff --git a/packages/scan-react/src/web/views/notifications/notification-header.tsx b/packages/scan-react/src/web/views/notifications/notification-header.tsx new file mode 100644 index 00000000..b90c0fcd --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/notification-header.tsx @@ -0,0 +1,121 @@ +import { cn } from '~web/utils/helpers'; +import { + NotificationEvent, + getComponentName, + getEventSeverity, + getTotalTime, +} from './data'; +import { CloseIcon } from './icons'; +import { signalWidgetViews } from '~web/state'; + +export const NotificationHeader = ({ + selectedEvent, +}: { + selectedEvent: NotificationEvent; +}) => { + const severity = getEventSeverity(selectedEvent); + switch (selectedEvent.kind) { + case 'interaction': { + return ( + // h-[48px] is a hack to adjust for header size +
+ {/* todo: make css variables for colors */} +
+
+ + {selectedEvent.type === 'click' ? 'Clicked ' : 'Typed in '} + + {getComponentName(selectedEvent.componentPath)} +
+ {getTotalTime(selectedEvent.timing).toFixed(0)}ms processing + time +
+
+
+
+ +
+
+
+
+ ); + } + case 'dropped-frames': { + return ( +
+
+
+ FPS Drop +
+ dropped to {selectedEvent.fps} FPS +
+
+ +
+
+ +
+
+
+
+ ); + } + } +}; diff --git a/packages/scan-react/src/web/views/notifications/notification-tabs.tsx b/packages/scan-react/src/web/views/notifications/notification-tabs.tsx new file mode 100644 index 00000000..73179555 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/notification-tabs.tsx @@ -0,0 +1,134 @@ +import { cn } from '~web/utils/helpers'; +import { NotificationEvent, useNotificationsContext } from './data'; +import { Popover } from './popover'; +import { VolumeOffIcon, VolumeOnIcon } from './icons'; +import { playNotificationSound } from '~core/utils'; + +export const NotificationTabs = ({ + selectedEvent: _, +}: { + selectedEvent: NotificationEvent; +}) => { + const { notificationState, setNotificationState, setRoute } = + useNotificationsContext(); + return ( +
+
+ + + +
+ { + setNotificationState((prev) => { + if ( + prev.audioNotificationsOptions.enabled && + prev.audioNotificationsOptions.audioContext.state !== 'closed' + ) { + prev.audioNotificationsOptions.audioContext.close(); + } + const prevEnabledState = prev.audioNotificationsOptions.enabled; + localStorage.setItem( + 'react-scan-notifications-audio', + String(!prevEnabledState), + ); + + const audioContext = new AudioContext(); + if (!prev.audioNotificationsOptions.enabled) { + playNotificationSound(audioContext); + } + if (prevEnabledState) { + audioContext.close(); + } + return { + ...prev, + audioNotificationsOptions: prevEnabledState + ? { + audioContext: null, + enabled: false, + } + : { + audioContext, + enabled: true, + }, + }; + }); + }} + className="ml-auto" + > +
+ Alerts + {notificationState.audioNotificationsOptions.enabled ? ( + + ) : ( + + )} +
+ + } + > + <>Play a chime when a slowdown is recorded +
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/notifications.tsx b/packages/scan-react/src/web/views/notifications/notifications.tsx new file mode 100644 index 00000000..e974f088 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/notifications.tsx @@ -0,0 +1,442 @@ +import { forwardRef, useEffect, useRef, useState } from "react"; +import { not_globally_unique_generateId, playNotificationSound } from "~core/utils"; +import { useToolbarEventLog } from "~core/notifications/event-tracking"; +import { FiberRenders } from "~core/notifications/performance"; +import { iife, invariantError } from "~core/notifications/performance-utils"; +import { cn } from "~web/utils/helpers"; +import { + NotificationStateContext, + NotificationsState, + getEventSeverity, + getTotalTime, + useNotificationsContext, +} from "./data"; +import { DetailsRoutes } from "./details-routes"; +import { NotificationHeader } from "./notification-header"; +import { fadeOutHighlights } from "./render-bar-chart"; +import { SlowdownHistory, useLaggedEvents } from "./slowdown-history"; + +const getGroupedFiberRenders = (fiberRenders: FiberRenders) => { + const res = Object.values(fiberRenders).map((render) => ({ + id: not_globally_unique_generateId(), + totalTime: render.nodeInfo.reduce((prev, curr) => prev + curr.selfTime, 0), + count: render.nodeInfo.length, + name: render.nodeInfo[0].name, // invariant, at least one exists, + deletedAll: false, + parents: render.parents, + hasMemoCache: render.hasMemoCache, + wasFiberRenderMount: render.wasFiberRenderMount, + // it would be nice if we calculated the % of components memoizable, but this would have to be calculated downstream before it got aggregated + elements: render.nodeInfo.map((node) => node.element), + changes: { + context: render.changes.fiberContext.current + .filter((change) => render.changes.fiberContext.changesCounts.get(change.name)) + .map((change) => ({ + name: String(change.name), + count: render.changes.fiberContext.changesCounts.get(change.name) ?? 0, + })), + props: render.changes.fiberProps.current + .filter((change) => render.changes.fiberProps.changesCounts.get(change.name)) + .map((change) => ({ + name: String(change.name), + count: render.changes.fiberProps.changesCounts.get(change.name) ?? 0, + })), + state: render.changes.fiberState.current + .filter((change) => render.changes.fiberState.changesCounts.get(Number(change.name))) + .map((change) => ({ + index: change.name as number, + count: render.changes.fiberState.changesCounts.get(Number(change.name)) ?? 0, + })), + }, + })); + + return res; +}; + +const useGarbageCollectElements = (notificationEvents: NotificationsState["events"]) => { + useEffect(() => { + const checkElementsExistence = () => { + notificationEvents.forEach((event) => { + if (!event.groupedFiberRenders) return; + + event.groupedFiberRenders.forEach((render) => { + if (render.deletedAll) return; + + if (!render.elements || render.elements.length === 0) { + render.deletedAll = true; + return; + } + + const initialLength = render.elements.length; + render.elements = render.elements.filter((element) => { + return element && element.isConnected; + }); + + if (render.elements.length === 0 && initialLength > 0) { + render.deletedAll = true; + } + }); + }); + }; + + const intervalId = setInterval(checkElementsExistence, 5000); + + return () => { + clearInterval(intervalId); + }; + }, [notificationEvents]); +}; + +export const useAppNotifications = () => { + const log = useToolbarEventLog(); + + const notificationEvents: NotificationsState["events"] = []; + + useGarbageCollectElements(notificationEvents); + + log.state.events.forEach((event) => { + const fiberRenders = + event.kind === "interaction" + ? event.data.meta.detailedTiming.fiberRenders + : event.data.meta.fiberRenders; + const groupedFiberRenders = getGroupedFiberRenders(fiberRenders); + const renderTime = groupedFiberRenders.reduce((prev, curr) => prev + curr.totalTime, 0); + switch (event.kind) { + case "interaction": { + const { commitEnd, jsEndDetail, interactionStartDetail, rafStart } = + event.data.meta.detailedTiming; + + // this is a known bug, js time doesn't backfill render time from async renders (or async js in general) + // the current impl is a close enough approximation so will leave as is until there is a dedicated effort to fix it + if (jsEndDetail - interactionStartDetail - renderTime < 0) { + invariantError("js time must be longer than render time"); + } + const otherJSTime = Math.max(0, jsEndDetail - interactionStartDetail - renderTime); + + const frameDraw = Math.max( + event.data.meta.latency - (commitEnd - interactionStartDetail), + 0, + ); + notificationEvents.push({ + componentPath: event.data.meta.detailedTiming.componentPath, + groupedFiberRenders, + id: event.id, + kind: "interaction", + memory: null, + timestamp: event.data.startAt, + type: + event.data.meta.detailedTiming.interactionType === "keyboard" ? "keyboard" : "click", + timing: { + renderTime: renderTime, + kind: "interaction", + otherJSTime, + framePreparation: rafStart - jsEndDetail, + frameConstruction: commitEnd - rafStart, + frameDraw, + }, + }); + return; + } + case "long-render": { + notificationEvents.push({ + kind: "dropped-frames", + id: event.id, + memory: null, + timing: { + kind: "dropped-frames", + renderTime: renderTime, + otherTime: event.data.meta.latency, + }, + groupedFiberRenders, + timestamp: event.data.startAt, + fps: event.data.meta.fps, + }); + return; + } + } + }); + return notificationEvents; +}; +const timeout = 1000; +const NotificationAudio = () => { + const { notificationState, setNotificationState } = useNotificationsContext(); + const playedFor = useRef(null); + const debounceTimeout = useRef(null); + const lastPlayedTime = useRef(0); + + const [laggedEvents] = useLaggedEvents(); + + const alertEventsCount = laggedEvents.filter( + // todo: make this configurable + (event) => getEventSeverity(event) === "high", + ).length; + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + // todo: sync with options + const audioEnabledString = localStorage.getItem("react-scan-notifications-audio"); + + if (audioEnabledString !== "false" && audioEnabledString !== "true") { + localStorage.setItem("react-scan-notifications-audio", "false"); + return; + } + + const audioEnabled = audioEnabledString === "false" ? false : true; + + if (audioEnabled) { + setNotificationState((prev) => { + if (prev.audioNotificationsOptions.enabled) { + return prev; + } + return { + ...prev, + audioNotificationsOptions: { + enabled: true, + audioContext: new AudioContext(), + }, + }; + }); + return; + } + }, []); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const { audioNotificationsOptions } = notificationState; + if (!audioNotificationsOptions.enabled) { + return; + } + if (alertEventsCount === 0) { + return; + } + if (playedFor.current && playedFor.current >= alertEventsCount) { + return; + } + + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + + const now = Date.now(); + const timeSinceLastPlay = now - lastPlayedTime.current; + const remainingDebounceTime = Math.max(0, timeout - timeSinceLastPlay); + + debounceTimeout.current = setTimeout(() => { + playNotificationSound(audioNotificationsOptions.audioContext); + playedFor.current = alertEventsCount; + lastPlayedTime.current = Date.now(); + debounceTimeout.current = null; + }, remainingDebounceTime); + }, [alertEventsCount]); + + useEffect(() => { + if (alertEventsCount !== 0) { + return; + } + playedFor.current = null; + }, [alertEventsCount]); + + useEffect(() => { + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + }; + }, []); + + return null; +}; + +export const NotificationWrapper = forwardRef((_, ref) => { + const events = useAppNotifications(); + const [notificationState, setNotificationState] = useState({ + detailsExpanded: false, + events, + filterBy: "latest", + moreInfoExpanded: false, + route: "render-visualization", + selectedEvent: events.toSorted((a, b) => a.timestamp - b.timestamp).at(-1) ?? null, + selectedFiber: null, + routeMessage: null, + audioNotificationsOptions: { + enabled: false, + audioContext: null, + }, + }); + + notificationState.events = events; + return ( + { + setNotificationState((prev) => { + const newState = { ...prev, route, routeMessage }; + switch (route) { + case "render-visualization": { + fadeOutHighlights(); + return { + ...newState, + selectedFiber: null, + }; + } + case "optimize": { + fadeOutHighlights(); + return { + ...newState, + selectedFiber: null, + }; + } + case "other-visualization": { + fadeOutHighlights(); + return { + ...newState, + selectedFiber: null, + }; + } + case "render-explanation": { + // it would be ideal not to fade this out, but need to spend the time to sync the outline positions as they change in a performant (this was solved in react scan just need to follow same semantics) + fadeOutHighlights(); + + return newState; + } + } + route satisfies never; + }); + }, + }} + > + + + + ); +}); +const Notifications = forwardRef((_, ref) => { + const { notificationState } = useNotificationsContext(); + + return ( +
+ {notificationState.selectedEvent && ( +
+ + {notificationState.moreInfoExpanded && } +
+ )} +
+
+ +
+
+ +
+
+
+ ); +}); + +const MoreInfo = () => { + const { notificationState } = useNotificationsContext(); + + if (!notificationState.selectedEvent) { + throw new Error("Invariant must have selected event for more info"); + } + + const event = notificationState.selectedEvent; + + return ( +
+
+ {iife(() => { + switch (event.kind) { + case "interaction": { + return ( + <> +
+ + {event.type === "click" + ? "Clicked component location" + : "Typed in component location"} + +
+ {event.componentPath.toReversed().map((part, i) => ( + <> + + {part} + + {i < event.componentPath.length - 1 && ( + + )} + + ))} +
+
+ +
+ Total Time + + {getTotalTime(event.timing).toFixed(0)}ms + +
+
+ Occurred + + {`${((Date.now() - event.timestamp) / 1000).toFixed(0)}s ago`} + +
+ + ); + } + case "dropped-frames": { + return ( + <> +
+ Total Time + + {getTotalTime(event.timing).toFixed(0)}ms + +
+ +
+ Occurred + + {`${((Date.now() - event.timestamp) / 1000).toFixed(0)}s ago`} + +
+ + ); + } + } + })} +
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/optimize.tsx b/packages/scan-react/src/web/views/notifications/optimize.tsx new file mode 100644 index 00000000..d5a3d205 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/optimize.tsx @@ -0,0 +1,517 @@ +import { useState } from "react"; +import { cn } from "~web/utils/helpers"; +import { GroupedFiberRender, NotificationEvent, getComponentName, getTotalTime } from "./data"; +import { iife } from "~core/notifications/performance-utils"; + +const formatReactData = (groupedFiberRenders: Array) => { + let text = ""; + + const filteredFibers = groupedFiberRenders + .toSorted((a, b) => b.totalTime - a.totalTime) + .slice(0, 30) + .filter((fiber) => fiber.totalTime > 5); + + filteredFibers.forEach((fiberRender) => { + let localText = ""; + + localText += "Component Name:"; + localText += fiberRender.name; + localText += "\n"; + + localText += `Rendered: ${fiberRender.count} times\n`; + localText += `Sum of self times for ${fiberRender.name} is ${fiberRender.totalTime.toFixed(0)}ms\n`; + if (fiberRender.changes.props.length > 0) { + localText += `Changed props for all ${fiberRender.name} instances ("name:count" pairs)\n`; + fiberRender.changes.props.forEach((change) => { + localText += `${change.name}:${change.count}x\n`; + }); + } + + if (fiberRender.changes.state.length > 0) { + localText += `Changed state for all ${fiberRender.name} instances ("hook index:count" pairs)\n`; + fiberRender.changes.state.forEach((change) => { + localText += `${change.index}:${change.count}x\n`; + }); + } + + if (fiberRender.changes.context.length > 0) { + localText += `Changed context for all ${fiberRender.name} instances ("context display name (if exists):count" pairs)\n`; + fiberRender.changes.context.forEach((change) => { + localText += `${change.name}:${change.count}x\n`; + }); + } + + text += localText; + text += "\n"; + }); + + return text; +}; + +const generateInteractionDataPrompt = ({ + renderTime, + eHandlerTimeExcludingRenders, + toRafTime, + commitTime, + framePresentTime, + formattedReactData, +}: { + renderTime: number; + eHandlerTimeExcludingRenders: number; + toRafTime: number; + commitTime: number; + framePresentTime: number | null; + formattedReactData: string; +}) => { + return `I will provide you with a set of high level, and low level performance data about an interaction in a React App: +### High level +- react component render time: ${renderTime.toFixed(0)}ms +- how long it took to run javascript event handlers (EXCLUDING REACT RENDERS): ${eHandlerTimeExcludingRenders.toFixed(0)}ms +- how long it took from the last event handler time, to the last request animation frame: ${toRafTime.toFixed(0)}ms + - things like prepaint, style recalculations, layerization, async web API's like observers may occur during this time +- how long it took from the last request animation frame to when the dom was committed: ${commitTime.toFixed(0)}ms + - during this period you will see paint, commit, potential style recalcs, and other misc browser activity. Frequently high times here imply css that makes the browser do a lot of work, or mutating expensive dom properties during the event handler stage. This can be many things, but it narrows the problem scope significantly when this is high +${framePresentTime === null ? "" : `- how long it took from dom commit for the frame to be presented: ${framePresentTime.toFixed(0)}ms. This is when information about how to paint the next frame is sent to the compositor threads, and when the GPU does work. If this is high, look for issues that may be a bottleneck for operations occurring during this time`} + +### Low level +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. +${formattedReactData}`; +}; + +const generateInteractionOptimizationPrompt = ({ + interactionType, + name, + componentPath, + time, + renderTime, + eHandlerTimeExcludingRenders, + toRafTime, + commitTime, + framePresentTime, + formattedReactData, +}: { + interactionType: string; + name: string; + componentPath: string; + + time: number; + renderTime: number; + eHandlerTimeExcludingRenders: number; + toRafTime: number; + commitTime: number; + framePresentTime: number | null; + formattedReactData: string; +}) => `You will attempt to implement a performance improvement to a user interaction in a React app. You will be provided with data about the interaction, and the slow down. + +Your should split your goals into 2 parts: +- identifying the problem +- fixing the problem + - it is okay to implement a fix even if you aren't 100% sure the fix solves the performance problem. When you aren't sure, you should tell the user to try repeating the interaction, and feeding the "Formatted Data" in the React Scan notifications optimize tab. This allows you to start a debugging flow with the user, where you attempt a fix, and observe the result. The user may make a mistake when they pass you the formatted data, so must make sure, given the data passed to you, that the associated data ties to the same interaction you were trying to debug. + + +Make sure to check if the user has the react compiler enabled (project dependent, configured through build tool), so you don't unnecessarily memoize components. If it is, you do not need to worry about memoizing user components + +One challenge you may face is the performance problem lies in a node_module, not in user code. If you are confident the problem originates because of a node_module, there are multiple strategies, which are context dependent: +- you can try to work around the problem, knowing which module is slow +- you can determine if its possible to resolve the problem in the node_module by modifying non node_module code +- you can monkey patch the node_module to experiment and see if it's really the problem (you can modify a functions properties to hijack the call for example) +- you can determine if it's feasible to replace whatever node_module is causing the problem with a performant option (this is an extreme) + +The interaction was a ${interactionType} on the component named ${name}. This component has the following ancestors ${componentPath}. This is the path from the component, to the root. This should be enough information to figure out where this component is in the user's code base + +This path is the component that was clicked, so it should tell you roughly where component had an event handler that triggered a state change. + +Please note that the leaf node of this path might not be user code (if they use a UI library), and they may contain many wrapper components that just pass through children that aren't relevant to the actual click. So make you sure analyze the path and understand what the user code is doing + +We have a set of high level, and low level data about the performance issue. + +The click took ${time.toFixed(0)}ms from interaction start, to when a new frame was presented to a user. + +We also provide you with a breakdown of what the browser spent time on during the period of interaction start to frame presentation. + +- react component render time: ${renderTime.toFixed(0)}ms +- how long it took to run javascript event handlers (EXCLUDING REACT RENDERS): ${eHandlerTimeExcludingRenders.toFixed(0)}ms +- how long it took from the last event handler time, to the last request animation frame: ${toRafTime.toFixed(0)}ms + - things like prepaint, style recalculations, layerization, async web API's like observers may occur during this time +- how long it took from the last request animation frame to when the dom was committed: ${commitTime.toFixed(0)}ms + - during this period you will see paint, commit, potential style recalcs, and other misc browser activity. Frequently high times here imply css that makes the browser do a lot of work, or mutating expensive dom properties during the event handler stage. This can be many things, but it narrows the problem scope significantly when this is high +${framePresentTime === null ? "" : `- how long it took from dom commit for the frame to be presented: ${framePresentTime.toFixed(0)}ms. This is when information about how to paint the next frame is sent to the compositor threads, and when the GPU does work. If this is high, look for issues that may be a bottleneck for operations occurring during this time`} + + +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. + +${formattedReactData} + +You may notice components have many renders, but much fewer props/state/context changes. This normally implies most of the components could have been memoized to avoid computation + +It's also important to remember if a component had no props/state/context change, and it was memoized, it would not render. So the flow should be: +- find the most expensive components +- see what's causing them to render +- determine how you can make those state/props/context not change for a large set of the renders +- once there are no more changes left, you can memoize the component so it no longer unnecessarily re-renders. + +An important thing to note is that if you see a lot of react renders (some components with very high render counts), but javascript excluding renders is much higher than render time, it is possible that the components with lots of renders run hooks like useEffect/useLayoutEffect, which run during the JS event handler period. + +It's also good to note that react profiles hook times in development, and if many hooks are called (lets say 5,000 components all called a useEffect), it will have to profile every single one. And it may also be the case the comparison of the hooks dependency can be expensive, and that would not be tracked in render time. + +If a node_module is the component with high renders, you can experiment to see if that component is the root issue (because of hooks). You should use the same instructions for node_module debugging mentioned previously. + +`; +const generateFrameDropOptimizationPrompt = ({ + renderTime, + otherTime, + formattedReactData, +}: { + renderTime: number; + + otherTime: number; + formattedReactData: string; +}) => `You will attempt to implement a performance improvement to a large slowdown in a react app + +Your should split your goals into 2 parts: +- identifying the problem +- fixing the problem + - it is okay to implement a fix even if you aren't 100% sure the fix solves the performance problem. When you aren't sure, you should tell the user to try repeating the interaction, and feeding the "Formatted Data" in the React Scan notifications optimize tab. This allows you to start a debugging flow with the user, where you attempt a fix, and observe the result. The user may make a mistake when they pass you the formatted data, so must make sure, given the data passed to you, that the associated data ties to the same interaction you were trying to debug. + +Make sure to check if the user has the react compiler enabled (project dependent, configured through build tool), so you don't unnecessarily memoize components. If it is, you do not need to worry about memoizing user components + +One challenge you may face is the performance problem lies in a node_module, not in user code. If you are confident the problem originates because of a node_module, there are multiple strategies, which are context dependent: +- you can try to work around the problem, knowing which module is slow +- you can determine if its possible to resolve the problem in the node_module by modifying non node_module code +- you can monkey patch the node_module to experiment and see if it's really the problem (you can modify a functions properties to hijack the call for example) +- you can determine if it's feasible to replace whatever node_module is causing the problem with a performant option (this is an extreme) + + +We have the high level time of how much react spent rendering, and what else the browser spent time on during this slowdown + +- react component render time: ${renderTime.toFixed(0)}ms +- other time: ${otherTime}ms + + +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. + +${formattedReactData} + +You may notice components have many renders, but much fewer props/state/context changes. This normally implies most of the components could have been memoized to avoid computation + +It's also important to remember if a component had no props/state/context change, and it was memoized, it would not render. So the flow should be: +- find the most expensive components +- see what's causing them to render +- determine how you can make those state/props/context not change for a large set of the renders +- once there are no more changes left, you can memoize the component so it no longer unnecessarily re-renders. + +An important thing to note is that if you see a lot of react renders (some components with very high render counts), but other time is much higher than render time, it is possible that the components with lots of renders run hooks like useEffect/useLayoutEffect, which run outside of what we profile (just react render time). + +It's also good to note that react profiles hook times in development, and if many hooks are called (lets say 5,000 components all called a useEffect), it will have to profile every single one. And it may also be the case the comparison of the hooks dependency can be expensive, and that would not be tracked in render time. + +If a node_module is the component with high renders, you can experiment to see if that component is the root issue (because of hooks). You should use the same instructions for node_module debugging mentioned previously. + +If renders don't seem to be the problem, see if there are any expensive CSS properties being added/mutated, or any expensive DOM Element mutations/new elements being created that could cause this slowdown. +`; + +const generateFrameDropExplanationPrompt = ({ + renderTime, + otherTime, + formattedReactData, +}: { + renderTime: number; + + otherTime: number; + formattedReactData: string; +}) => `Your goal will be to help me find the source of a performance problem in a React App. I collected a large dataset about this specific performance problem. + +We have the high level time of how much react spent rendering, and what else the browser spent time on during this slowdown + +- react component render time: ${renderTime.toFixed(0)}ms +- other time (other JavaScript, hooks like useEffect, style recalculations, layerization, paint & commit and everything else the browser might do to draw a new frame after javascript mutates the DOM): ${otherTime}ms + + +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. + +${formattedReactData} + +You may notice components have many renders, but much fewer props/state/context changes. This normally implies most of the components could have been memoized to avoid computation + +It's also important to remember if a component had no props/state/context change, and it was memoized, it would not render. So a flow we can go through is: +- find the most expensive components +- see what's causing them to render +- determine how you can make those state/props/context not change for a large set of the renders +- once there are no more changes left, you can memoize the component so it no longer unnecessarily re-renders. + + +An important thing to note is that if you see a lot of react renders (some components with very high render counts), but other time is much higher than render time, it is possible that the components with lots of renders run hooks like useEffect/useLayoutEffect, which run outside of what we profile (just react render time). + +It's also good to note that react profiles hook times in development, and if many hooks are called (lets say 5,000 components all called a useEffect), it will have to profile every single one, and this can add significant overhead when thousands of effects ran. + +If it's not possible to explain the root problem from this data, please ask me for more data explicitly, and what we would need to know to find the source of the performance problem. +`; + +const generateFrameDropDataPrompt = ({ + renderTime, + otherTime, + formattedReactData, +}: { + renderTime: number; + + otherTime: number; + formattedReactData: string; +}) => `I will provide you with a set of high level, and low level performance data about a large frame drop in a React App: +### High level +- react component render time: ${renderTime.toFixed(0)}ms +- how long it took to run everything else (other JavaScript, hooks like useEffect, style recalculations, layerization, paint & commit and everything else the browser might do to draw a new frame after javascript mutates the DOM): ${otherTime}ms + +### Low level +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. +${formattedReactData}`; + +const generateInteractionExplanationPrompt = ({ + interactionType, + name, + time, + renderTime, + eHandlerTimeExcludingRenders, + toRafTime, + commitTime, + framePresentTime, + formattedReactData, +}: { + interactionType: string; + name: string; + time: number; + renderTime: number; + eHandlerTimeExcludingRenders: number; + toRafTime: number; + commitTime: number; + framePresentTime: number | null; + formattedReactData: string; +}) => `Your goal will be to help me find the source of a performance problem. I collected a large dataset about this specific performance problem. + +There was a ${interactionType} on a component named ${name}. This means, roughly, the component that handled the ${interactionType} event was named ${name}. + +We have a set of high level, and low level data about the performance issue. + +The click took ${time.toFixed(0)}ms from interaction start, to when a new frame was presented to a user. + +We also provide you with a breakdown of what the browser spent time on during the period of interaction start to frame presentation. + +- react component render time: ${renderTime.toFixed(0)}ms +- how long it took to run javascript event handlers (EXCLUDING REACT RENDERS): ${eHandlerTimeExcludingRenders.toFixed(0)}ms +- how long it took from the last event handler time, to the last request animation frame: ${toRafTime.toFixed(0)}ms + - things like prepaint, style recalculations, layerization, async web API's like observers may occur during this time +- how long it took from the last request animation frame to when the dom was committed: ${commitTime.toFixed(0)}ms + - during this period you will see paint, commit, potential style recalcs, and other misc browser activity. Frequently high times here imply css that makes the browser do a lot of work, or mutating expensive dom properties during the event handler stage. This can be many things, but it narrows the problem scope significantly when this is high +${framePresentTime === null ? "" : `- how long it took from dom commit for the frame to be presented: ${framePresentTime.toFixed(0)}ms. This is when information about how to paint the next frame is sent to the compositor threads, and when the GPU does work. If this is high, look for issues that may be a bottleneck for operations occurring during this time`} + +We also have lower level information about react components, such as their render time, and which props/state/context changed when they re-rendered. + +${formattedReactData} + + +You may notice components have many renders, but much fewer props/state/context changes. This normally implies most of the components could have been memoized to avoid computation + +It's also important to remember if a component had no props/state/context change, and it was memoized, it would not render. So a flow we can go through is: +- find the most expensive components +- see what's causing them to render +- determine how you can make those state/props/context not change for a large set of the renders +- once there are no more changes left, you can memoize the component so it no longer unnecessarily re-renders. + + +An important thing to note is that if you see a lot of react renders (some components with very high render counts), but javascript excluding renders is much higher than render time, it is possible that the components with lots of renders run hooks like useEffect/useLayoutEffect, which run during the JS event handler period. + +It's also good to note that react profiles hook times in development, and if many hooks are called (lets say 5,000 components all called a useEffect), it will have to profile every single one. And it may also be the case the comparison of the hooks dependency can be expensive, and that would not be tracked in render time. + +If it's not possible to explain the root problem from this data, please ask me for more data explicitly, and what we would need to know to find the source of the performance problem. +`; +export const getLLMPrompt = ( + activeTab: "fix" | "data" | "explanation", + selectedEvent: NotificationEvent, +) => + iife(() => { + switch (activeTab) { + case "data": { + switch (selectedEvent.kind) { + case "dropped-frames": { + return generateFrameDropDataPrompt({ + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + otherTime: selectedEvent.timing.otherTime, + }); + } + case "interaction": { + return generateInteractionDataPrompt({ + commitTime: selectedEvent.timing.frameConstruction, + eHandlerTimeExcludingRenders: selectedEvent.timing.otherJSTime, + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + framePresentTime: selectedEvent.timing.frameDraw, + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + toRafTime: selectedEvent.timing.framePreparation, + }); + } + } + } + case "explanation": { + switch (selectedEvent.kind) { + case "dropped-frames": { + return generateFrameDropExplanationPrompt({ + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + otherTime: selectedEvent.timing.otherTime, + }); + } + case "interaction": { + return generateInteractionExplanationPrompt({ + commitTime: selectedEvent.timing.frameConstruction, + eHandlerTimeExcludingRenders: selectedEvent.timing.otherJSTime, + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + framePresentTime: selectedEvent.timing.frameDraw, + interactionType: selectedEvent.type, + name: getComponentName(selectedEvent.componentPath), + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + time: getTotalTime(selectedEvent.timing), + toRafTime: selectedEvent.timing.framePreparation, + }); + } + } + } + case "fix": { + switch (selectedEvent.kind) { + case "dropped-frames": { + return generateFrameDropOptimizationPrompt({ + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + otherTime: selectedEvent.timing.otherTime, + }); + } + case "interaction": { + return generateInteractionOptimizationPrompt({ + commitTime: selectedEvent.timing.frameConstruction, + componentPath: selectedEvent.componentPath.join(">"), + eHandlerTimeExcludingRenders: selectedEvent.timing.otherJSTime, + formattedReactData: formatReactData(selectedEvent.groupedFiberRenders), + framePresentTime: selectedEvent.timing.frameDraw, + interactionType: selectedEvent.type, + name: getComponentName(selectedEvent.componentPath), + renderTime: selectedEvent.groupedFiberRenders.reduce( + (prev, curr) => prev + curr.totalTime, + 0, + ), + time: getTotalTime(selectedEvent.timing), + toRafTime: selectedEvent.timing.framePreparation, + }); + } + } + } + } + }); + +export const Optimize = ({ selectedEvent }: { selectedEvent: NotificationEvent }) => { + const [activeTab, setActiveTab] = useState<"fix" | "explanation" | "data">("fix"); + const [copying, setCopying] = useState(false); + + return ( +
+
+
+
+ + + + +
+
+
+
+            {getLLMPrompt(activeTab, selectedEvent)}
+          
+
+
+ +
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/other-visualization.tsx b/packages/scan-react/src/web/views/notifications/other-visualization.tsx new file mode 100644 index 00000000..fe4aaf80 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/other-visualization.tsx @@ -0,0 +1,666 @@ +import { ReactNode } from "react"; +import { useContext, useEffect, useState } from "react"; +import { getIsProduction } from "~core/index"; +import { iife } from "~core/notifications/performance-utils"; +import { cn } from "~web/utils/helpers"; +import { InteractionEvent, NotificationEvent, getTotalTime, useNotificationsContext } from "./data"; +import { getLLMPrompt } from "./optimize"; +import { ToolbarElementContext } from "~web/widget"; +type BaseTimeDataItem = { + name: string; + time: number; + color: string; + kind: + | "other-not-javascript" + | "other-javascript" + | "render" + | "other-frame-drop" + | "total-processing-time"; +}; + +type TimeData = Array; + +const getTimeData = (selectedEvent: NotificationEvent, isProduction: boolean) => { + switch (selectedEvent.kind) { + // todo: push instead of conditional spread + case "dropped-frames": { + const timeData: TimeData = [ + ...(isProduction + ? [ + { + name: "Total Processing Time", + time: getTotalTime(selectedEvent.timing), + color: "bg-red-500", + kind: "total-processing-time" as const, + }, + ] + : [ + { + name: "Renders", + time: selectedEvent.timing.renderTime, + color: "bg-purple-500", + kind: "render" as const, + }, + { + name: "JavaScript, DOM updates, Draw Frame", + time: selectedEvent.timing.otherTime, + color: "bg-[#4b4b4b]", + kind: "other-frame-drop" as const, + }, + ]), + ]; + return timeData; + } + case "interaction": { + const timeData: TimeData = [ + ...(!isProduction + ? [ + { + name: "Renders", + time: selectedEvent.timing.renderTime, + color: "bg-purple-500", + kind: "render" as const, + }, + ] + : []), + { + name: isProduction ? "React Renders, Hooks, Other JavaScript" : "JavaScript/React Hooks ", + time: selectedEvent.timing.otherJSTime, + color: "bg-[#EFD81A]", + + kind: "other-javascript", + }, + + { + name: "Update DOM and Draw New Frame", + time: + getTotalTime(selectedEvent.timing) - + selectedEvent.timing.renderTime - + selectedEvent.timing.otherJSTime, + color: "bg-[#1D3A66]", + kind: "other-not-javascript", + }, + ]; + + return timeData; + } + } +}; + +export const OtherVisualization = ({ selectedEvent }: { selectedEvent: NotificationEvent }) => { + const [isProduction] = useState(getIsProduction() ?? false); + const { notificationState } = useNotificationsContext(); + const [expandedItems, setExpandedItems] = useState( + notificationState.routeMessage?.name ? [notificationState.routeMessage.name] : [], + ); + const timeData = getTimeData(selectedEvent, isProduction); + 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 + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (notificationState.routeMessage?.name) { + const container = root?.querySelector("#overview-scroll-container"); + const element = root?.querySelector( + `#react-scan-overview-bar-${notificationState.routeMessage.name}`, + ) as HTMLElement; + + if (container && element) { + const elementTop = element.getBoundingClientRect().top; + const containerTop = container.getBoundingClientRect().top; + const scrollOffset = elementTop - containerTop; + container.scrollTop = container.scrollTop + scrollOffset; + } + } + }, [notificationState.route]); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (notificationState.route === "other-visualization") { + setExpandedItems((prev) => + notificationState.routeMessage?.name ? [notificationState.routeMessage.name] : prev, + ); + } + }, [notificationState.route]); + + const totalTime = timeData.reduce((acc, item) => acc + item.time, 0); + + return ( +
+
+
+

What was time spent on?

+ Total: {totalTime.toFixed(0)}ms +
+
+
+ {timeData.map((entry) => { + const isExpanded = expandedItems.includes(entry.kind); + return ( +
+ + {isExpanded && ( +
+

+ {iife(() => { + switch (selectedEvent.kind) { + case "interaction": { + switch (entry.kind) { + case "render": { + return ; + } + + case "other-javascript": { + return ; + } + + case "other-not-javascript": { + return ; + } + } + } + case "dropped-frames": { + switch (entry.kind) { + case "total-processing-time": { + return ( + + ); + } + case "render": { + return ( + <> + b.totalTime - a.totalTime) + .slice(0, 3) + .map((render) => ({ + name: render.name, + percentage: + render.totalTime / getTotalTime(selectedEvent.timing), + })), + }, + }} + /> + + ); + } + case "other-frame-drop": { + return ( + + ); + } + } + } + } + })} +

+
+ )} +
+ ); + })} +
+
+ ); +}; + +type OverviewInput = + | { + kind: "js-explanation-base"; + } + | { + kind: "total-processing"; + data: { + time: number; + }; + } + | { + kind: "high-render-count-high-js"; + data: { + renderCount: number; + topByCount: Array<{ name: string; count: number }>; + }; + } + | { + kind: "low-render-count-high-js"; + data: { + renderCount: number; + }; + } + | { + kind: "high-render-count-update-dom-draw-frame"; + data: { + count: number; + percentageOfTotal: number; + copyButton: ReactNode; + }; + } + | { + kind: "update-dom-draw-frame"; + data: { + copyButton: ReactNode; + }; + } + | { + kind: "render"; + data: { topByTime: Array<{ name: string; percentage: number }> }; + } + | { + kind: "other"; + }; + +const getDrawInput = (event: InteractionEvent): OverviewInput => { + const renderCount = event.groupedFiberRenders.reduce((prev, curr) => prev + curr.count, 0); + + const renderTime = event.timing.renderTime; + const totalTime = getTotalTime(event.timing); + const renderPercentage = (renderTime / totalTime) * 100; + + if (renderCount > 100) { + return { + kind: "high-render-count-update-dom-draw-frame", + data: { + count: renderCount, + percentageOfTotal: renderPercentage, + copyButton: , + }, + }; + } + + return { + kind: "update-dom-draw-frame", + data: { + copyButton: , + }, + }; +}; + +const CopyPromptButton = () => { + const [copying, setCopying] = useState(false); + const { notificationState } = useNotificationsContext(); + + return ( + + ); +}; + +const getRenderInput = (event: InteractionEvent): OverviewInput => { + if (event.timing.renderTime / getTotalTime(event.timing) > 0.3) { + return { + kind: "render", + data: { + topByTime: event.groupedFiberRenders + .toSorted((a, b) => b.totalTime - a.totalTime) + .slice(0, 3) + .map((e) => ({ + percentage: e.totalTime / getTotalTime(event.timing), + name: e.name, + })), + }, + }; + } + + return { + kind: "other", + }; +}; + +const getJSInput = (event: InteractionEvent): OverviewInput => { + const renderCount = event.groupedFiberRenders.reduce((prev, curr) => prev + curr.count, 0); + if (event.timing.otherJSTime / getTotalTime(event.timing) < 0.2) { + return { + kind: "js-explanation-base", + }; + } + if ( + event.groupedFiberRenders.find((render) => render.count > 200) || + event.groupedFiberRenders.reduce((prev, curr) => prev + curr.count, 0) > 500 + ) { + // not sure a great heuristic for picking the render count + return { + kind: "high-render-count-high-js", + data: { + renderCount, + topByCount: event.groupedFiberRenders + .filter((groupedRender) => groupedRender.count > 100) + .toSorted((a, b) => b.count - a.count) + .slice(0, 3), + }, + }; + } + if (event.timing.otherJSTime / getTotalTime(event.timing) > 0.3) { + if (event.timing.renderTime > 0.2) { + return { + kind: "js-explanation-base", + }; + } + + return { + kind: "low-render-count-high-js", + data: { + renderCount, + }, + }; + } + + return { + kind: "js-explanation-base", + }; +}; + +const Explanation = ({ input }: { input: OverviewInput }) => { + switch (input.kind) { + case "total-processing": { + return ( +
+

+ This is the time it took to draw the entire frame that was presented to the user. To be + at 60FPS, this number needs to be {"<=16ms"} +

+ +

+ To debug the issue, check the "Ranked" tab to see if there are significant component + renders +

+

+ On a production React build, React Scan can't access the time it took for component to + render. To get that information, run React Scan on a development build +

+ +

+ To understand precisely what caused the slowdown while in production, use the{" "} + Chrome profiler and analyze the function call times. +

+ +

+
+ ); + } + case "render": { + return ( +
+

+ This is the time it took React to run components, and internal logic to handle the + output of your component. +

+ +
+

The slowest components for this time period were:

+ {input.data.topByTime.map((item) => ( +
+ {item.name}: {(item.percentage * 100).toFixed(0)}% of total +
+ ))} +
+

+ To view the render times of all your components, and what caused them to render, go to + the "Ranked" tab +

+

The "Ranked" tab shows the render times of every component.

+

The render times of the same components are grouped together into one bar.

+

+ Clicking the component will show you what props, state, or context caused the component + to re-render. +

+
+ ); + } + case "js-explanation-base": { + return ( +
+

+ This is the period when JavaScript hooks and other JavaScript outside of React Renders + run. +

+

+ The most common culprit for high JS time is expensive hooks, like expensive callbacks + inside of useEffect's or a large number of useEffect's called, but this can + also be JavaScript event handlers ('onclick', 'onchange') that + performed expensive computation. +

+

+ If you have lots of components rendering that call hooks, like useEffect, it can add + significant overhead even if the callbacks are not expensive. If this is the case, you + can try optimizing the renders of those components to avoid the hook from having to run. +

+

+ You should profile your app using the Chrome DevTools profiler to learn + exactly which functions took the longest to execute. +

+
+ ); + } + case "high-render-count-high-js": { + return ( +
+

+ This is the period when JavaScript hooks and other JavaScript outside of React Renders + run. +

+ {input.data.renderCount === 0 ? ( + <> +

+ There were no renders, which means nothing related to React caused this slowdown. + The most likely cause of the slowdown is a slow JavaScript event handler, or code + related to a Web API +

+

+ You should try to reproduce the slowdown while profiling your website with the + Chrome DevTools profiler to see exactly what functions took the + longest to execute. +

+ + ) : ( + <> + {" "} +

+ There were {input.data.renderCount} renders, which could have + contributed to the high JavaScript/Hook time if they ran lots of hooks, like{" "} + useEffects. +

+
+

You should try optimizing the renders of:

+ {input.data.topByCount.map((item) => ( +
+ - {item.name} (rendered {item.count}x) +
+ ))} +
+ and then checking if the problem still exists. +

+ You can also try profiling your app using the{" "} + Chrome DevTools profiler to see exactly what functions took the + longest to execute. +

+ + )} +
+ ); + } + case "low-render-count-high-js": { + return ( +
+

+ This is the period when JavaScript hooks and other JavaScript outside of React Renders + run. +

+

+ There were only {input.data.renderCount} renders detected, which means + either you had very expensive hooks like useEffect/ + useLayoutEffect, or there is other JavaScript running during this + interaction that took up the majority of the time. +

+

+ To understand precisely what caused the slowdown, use the{" "} + Chrome profiler and analyze the function call times. +

+
+ ); + } + case "high-render-count-update-dom-draw-frame": { + return ( +
+

+ These are the calculations the browser is forced to do in response to the JavaScript + that ran during the interaction. +

+

+ This can be caused by CSS updates/CSS recalculations, or new DOM elements/DOM mutations. +

+

+ During this interaction, there were {input.data.count} renders, which + was {input.data.percentageOfTotal.toFixed(0)}% of the time spent + processing +

+

+ The work performed as a result of the renders may have forced the browser to spend a lot + of time to draw the next frame. +

+

+ You can try optimizing the renders to see if the performance problem still exists using + the "Ranked" tab. +

+

+ If you use an AI-based code editor, you can export the performance data collected as a + prompt. +

+ +

{input.data.copyButton}

+

+ Provide this formatted data to the model and ask it to find, or fix, what could be + causing this performance problem. +

+

For a larger selection of prompts, try the "Prompts" tab

+
+ ); + } + case "update-dom-draw-frame": { + return ( +
+

+ These are the calculations the browser is forced to do in response to the JavaScript + that ran during the interaction. +

+

+ This can be caused by CSS updates/CSS recalculations, or new DOM elements/DOM mutations. +

+

+ If you use an AI-based code editor, you can export the performance data collected as a + prompt. +

+ +

{input.data.copyButton}

+

+ Provide this formatted data to the model and ask it to find, or fix, what could be + causing this performance problem. +

+

For a larger selection of prompts, try the "Prompts" tab

+
+ ); + } + case "other": { + return ( +
+

+ This is the time it took to run everything other than React renders. This can be hooks + like useEffect, other JavaScript not part of React, or work the browser has + to do to update the DOM and draw the next frame. +

+

+ To get a better picture of what happened, profile your app using the{" "} + Chrome profiler when the performance problem arises. +

+
+ ); + } + } +}; diff --git a/packages/scan-react/src/web/views/notifications/popover.tsx b/packages/scan-react/src/web/views/notifications/popover.tsx new file mode 100644 index 00000000..cc8d466f --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/popover.tsx @@ -0,0 +1,185 @@ +import { + ComponentProps, + ReactNode, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '~web/utils/helpers'; +import { ToolbarElementContext } from '~web/widget'; + +type PopoverState = 'closed' | 'opening' | 'open' | 'closing'; + +/** + * + * fixme: very hacky and suboptimal popover (api and implementation) + */ +export const Popover = ({ + children, + triggerContent, + wrapperProps, +}: { + children: ReactNode; + triggerContent: ReactNode; + wrapperProps?: ComponentProps<'div'>; +}) => { + const [popoverState, setPopoverState] = useState('closed'); + const [elBoundingRect, setElBoundingRect] = useState(null); + const [viewportSize, setViewportSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const portalEl = useContext(ToolbarElementContext); + const isHoveredRef = useRef(false); + + useEffect(() => { + const handleResize = () => { + setViewportSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + updateRect(); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const updateRect = () => { + if (triggerRef.current && portalEl) { + const triggerRect = triggerRef.current.getBoundingClientRect(); + const portalRect = portalEl.getBoundingClientRect(); + + const centerX = triggerRect.left + triggerRect.width / 2; + const centerY = triggerRect.top; + + const rect = new DOMRect( + centerX - portalRect.left, + centerY - portalRect.top, + triggerRect.width, + triggerRect.height, + ); + setElBoundingRect(rect); + } + }; + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + updateRect(); + }, [triggerRef.current]); + + useEffect(() => { + if (popoverState === 'opening') { + const timer = setTimeout(() => setPopoverState('open'), 120); + return () => clearTimeout(timer); + } else if (popoverState === 'closing') { + const timer = setTimeout(() => setPopoverState('closed'), 120); + return () => clearTimeout(timer); + } + }, [popoverState]); + + // just incase we didn't capture the mouse leave event because the underlying container moved + useEffect(() => { + const interval = setInterval(() => { + if (!isHoveredRef.current && popoverState !== 'closed') { + setPopoverState('closing'); + } + }, 1000); + + return () => clearInterval(interval); + }, [popoverState]); + + const handleMouseEnter = () => { + isHoveredRef.current = true; + updateRect(); + setPopoverState('opening'); + }; + + const handleMouseLeave = () => { + isHoveredRef.current = false; + updateRect(); + setPopoverState('closing'); + }; + + const getPopoverPosition = () => { + if (!elBoundingRect || !portalEl) return { top: 0, left: 0 }; + + const portalRect = portalEl.getBoundingClientRect(); + const popoverWidth = 175; + const popoverHeight = popoverRef.current?.offsetHeight || 40; + const safeArea = 5; + + const viewportX = elBoundingRect.x + portalRect.left; + const viewportY = elBoundingRect.y + portalRect.top; + + let left = viewportX; + let top = viewportY - 4; + + if (left - popoverWidth / 2 < safeArea) { + left = safeArea + popoverWidth / 2; + } else if (left + popoverWidth / 2 > viewportSize.width - safeArea) { + left = viewportSize.width - safeArea - popoverWidth / 2; + } + + if (top - popoverHeight < safeArea) { + top = viewportY + elBoundingRect.height + 4; + } + + return { + top: top - portalRect.top, + left: left - portalRect.left, + }; + }; + + const popoverPosition = getPopoverPosition(); + + return ( + <> + {portalEl && + elBoundingRect && + popoverState !== 'closed' && + createPortal( +
+ {children} +
, + portalEl, + )} + +
+ {triggerContent} +
+ + ); +}; diff --git a/packages/scan-react/src/web/views/notifications/render-bar-chart.tsx b/packages/scan-react/src/web/views/notifications/render-bar-chart.tsx new file mode 100644 index 00000000..2cd34b54 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/render-bar-chart.tsx @@ -0,0 +1,493 @@ +import { useRef, useState } from "react"; +import { getBatchedRectMap } from "../../../../../scan/src/new-outlines"; +import { getIsProduction } from "~core/index"; +import { iife } from "~core/notifications/performance-utils"; +import { cn } from "~web/utils/helpers"; +import { + GroupedFiberRender, + NotificationEvent, + getTotalTime, + isRenderMemoizable, + useNotificationsContext, +} from "./data"; +import { HighlightStore, drawHighlights } from "~core/notifications/outline-overlay"; +import { ChevronRight } from "./icons"; + +// todo: cleanup, convoluted ternaries +export const fadeOutHighlights = () => { + const curr = HighlightStore.value.current + ? HighlightStore.value.current + : HighlightStore.value.kind === "transition" + ? HighlightStore.value.transitionTo + : null; + if (!curr) { + return; + } + + if (HighlightStore.value.kind === "transition") { + HighlightStore.value = { + kind: "move-out", + // because we want to dynamically fade this value + current: + HighlightStore.value.current?.alpha === 0 + ? // we want to only start fading from transition if current is done animating out + HighlightStore.value.transitionTo + : // if current doesn't exist then transition must exist + (HighlightStore.value.current ?? HighlightStore.value.transitionTo), + }; + return; + } + + HighlightStore.value = { + kind: "move-out", + current: { + alpha: 0, + ...curr, + }, + }; +}; + +type Bars = Array< + | { kind: "other-frame-drop"; totalTime: number } + | { kind: "other-not-javascript"; totalTime: number } + | { kind: "other-javascript"; totalTime: number } + | { kind: "render"; event: GroupedFiberRender; totalTime: number } +>; + +export const RenderBarChart = ({ selectedEvent }: { selectedEvent: NotificationEvent }) => { + const totalInteractionTime = getTotalTime(selectedEvent.timing); + const nonRender = totalInteractionTime - selectedEvent.timing.renderTime; + const [isProduction] = useState(getIsProduction()); + const events = selectedEvent.groupedFiberRenders; + const bars: Bars = events.map((event) => ({ + event, + kind: "render", + totalTime: isProduction ? event.count : event.totalTime, + })); + + const isShowingExtraInfo = iife(() => { + switch (selectedEvent.kind) { + case "dropped-frames": { + return selectedEvent.timing.renderTime / totalInteractionTime < 0.1; + } + case "interaction": { + return ( + (selectedEvent.timing.otherJSTime + selectedEvent.timing.renderTime) / + totalInteractionTime < + 0.2 + ); + } + } + }); + /** + * We don't add the extra bars in production because we can't compare them to the renders, so the bar is useless, user can use overview tab to see times + */ + if (selectedEvent.kind === "interaction" && !isProduction) { + bars.push({ + kind: "other-javascript", + totalTime: selectedEvent.timing.otherJSTime, + }); + } + + if (isShowingExtraInfo && !isProduction) { + if (selectedEvent.kind === "interaction") { + bars.push({ + kind: "other-not-javascript", + totalTime: + getTotalTime(selectedEvent.timing) - + selectedEvent.timing.renderTime - + selectedEvent.timing.otherJSTime, + }); + } else { + bars.push({ + kind: "other-frame-drop", + totalTime: nonRender, + }); + } + } + + const debouncedMouseEnter = useRef<{ + timer: ReturnType | null; + lastCallAt: number | null; + }>({ + lastCallAt: null, + timer: null, + }); + + const totalBarTime = bars.reduce((prev, curr) => prev + curr.totalTime, 0); + + return ( +
+ {iife(() => { + if (isProduction && bars.length === 0) { + return ( +
+

No data available

+

No data was collected during this period

+
+ ); + } + if (bars.length === 0) { + return ( +
+

No renders collected

+

There were no renders during this period

+
+ ); + } + })} + + {bars + .toSorted((a, b) => b.totalTime - a.totalTime) + .map((bar) => ( + + ))} +
+ ); +}; + +const getTransitionState = (state: { + current: { alpha: number } | null; + transitionTo: { alpha: number }; +}) => { + if (!state.current) { + return "fading-in"; + } + if (state.current.alpha > 0) { + return "fading-out" as const; + } + return "fading-in" as const; +}; + +const RenderBar = ({ + bar, + debouncedMouseEnter, + totalBarTime, + isProduction, + bars, + depth = 0, +}: { + depth?: number; + bars: Bars; + bar: Bars[number]; + debouncedMouseEnter: { + current: { + timer: ReturnType | null; + lastCallAt: number | null; + }; + }; + totalBarTime: number; + isProduction: boolean | null; +}) => { + const { setNotificationState, setRoute } = useNotificationsContext(); + const [isExpanded, setIsExpanded] = useState(false); + + const isLeaf = bar.kind === "render" ? bar.event.parents.size === 0 : true; + + const parentBars = bars.filter((otherBar) => + otherBar.kind === "render" && bar.kind === "render" + ? bar.event.parents.has(otherBar.event.name) && otherBar.event.name !== bar.event.name + : false, + ); + + const missingParentNames = + bar.kind === "render" + ? Array.from(bar.event.parents).filter( + (parentName) => !bars.some((b) => b.kind === "render" && b.event.name === parentName), + ) + : []; + + const handleBarClick = () => { + if (bar.kind === "render") { + setNotificationState((prev) => ({ + ...prev, + selectedFiber: bar.event, + })); + + setRoute({ + route: "render-explanation", + routeMessage: null, + }); + } else { + setRoute({ + route: "other-visualization", + routeMessage: { + kind: "auto-open-overview-accordion", + name: bar.kind, + }, + }); + } + }; + + return ( +
+
+ + + + + {depth === 0 && ( +
+ Click to learn more +
+ )} +
+ + {isExpanded && (parentBars.length > 0 || missingParentNames.length > 0) && ( +
+ {parentBars + .toSorted((a, b) => b.totalTime - a.totalTime) + .map((parentBar, i) => ( + + ))} + {missingParentNames.map((parentName) => ( +
+
+
+
+
+ + {parentName} + +
+
+
+
+ ))} +
+ )} +
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/render-explanation.tsx b/packages/scan-react/src/web/views/notifications/render-explanation.tsx new file mode 100644 index 00000000..e8858230 --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/render-explanation.tsx @@ -0,0 +1,259 @@ +import { cn } from '~web/utils/helpers'; +import { NotificationEvent, useNotificationsContext } from './data'; +import { useLayoutEffect, useState } from 'react'; +import { ArrowLeft, CloseIcon } from './icons'; +import { getIsProduction } from '~core/index'; + +export const RenderExplanation = ({ + selectedEvent: _, + selectedFiber, +}: { + selectedFiber: NotificationEvent['groupedFiberRenders'][number]; + selectedEvent: NotificationEvent; +}) => { + const { setRoute } = useNotificationsContext(); + const [tipisShown, setTipIsShown] = useState(true); + const [isProduction] = useState(getIsProduction()); + + useLayoutEffect(() => { + const res = localStorage.getItem('react-scan-tip-shown'); + const asBool = res === 'true' ? true : res === 'false' ? false : null; + if (asBool === null) { + setTipIsShown(true); + localStorage.setItem('react-scan-tip-is-shown', 'true'); + return; + } + if (!asBool) { + setTipIsShown(false); + } + }, []); + const isMemoizable = + selectedFiber.changes.context.length === 0 && + selectedFiber.changes.props.length === 0 && + selectedFiber.changes.state.length === 0; + return ( +
+
+ +
+
+
+ {selectedFiber.name} +
+
+
+ {!isProduction && ( + <> +
+ • Render time: {selectedFiber.totalTime.toFixed(0)}ms +
+ + )} +
+ • Renders: {selectedFiber.count}x +
+
+
+
+ {tipisShown && !isMemoizable && ( +
+ +
+
+
+ How to stop renders +
+
+ Stop the following props, state and context from changing between + renders, and wrap the component in React.memo if not already +
+
+
+ )} + + {isMemoizable && ( +
+
+
+
+ No changes detected +
+
+ This component would not have rendered if it was memoized +
+
+
+ )} +
+
+
+ Changed Props +
+ {selectedFiber.changes.props.length > 0 ? ( + selectedFiber.changes.props + .toSorted((a, b) => b.count - a.count) + .map((change) => ( +
+ {change.name} +
+ {change.count}/{selectedFiber.count}x +
+
+ )) + ) : ( +
+ No changes +
+ )} +
+
+
+ Changed State +
+ {selectedFiber.changes.state.length > 0 ? ( + selectedFiber.changes.state + .toSorted((a, b) => b.count - a.count) + .map((change) => ( +
+ + index {change.index} + +
+ {change.count}/{selectedFiber.count}x +
+
+ )) + ) : ( +
+ No changes +
+ )} +
+
+
+ Changed Context +
+ {selectedFiber.changes.context.length > 0 ? ( + selectedFiber.changes.context + + .toSorted((a, b) => b.count - a.count) + .map((change) => ( +
+ {change.name} +
+ {change.count}/{selectedFiber.count}x +
+
+ )) + ) : ( +
+ No changes +
+ )} +
+
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/notifications/slowdown-history.tsx b/packages/scan-react/src/web/views/notifications/slowdown-history.tsx new file mode 100644 index 00000000..2cfe868b --- /dev/null +++ b/packages/scan-react/src/web/views/notifications/slowdown-history.tsx @@ -0,0 +1,437 @@ +import { useEffect, useRef, useState } from 'react'; +import { cn } from '~web/utils/helpers'; +import { + InteractionEvent, + NotificationEvent, + getComponentName, + getEventSeverity, + getTotalTime, + useNotificationsContext, +} from './data'; +import { + ClearIcon, + KeyboardIcon, + PointerIcon, + TrendingDownIcon, +} from './icons'; +import { Popover } from './popover'; +import { iife } from '~core/notifications/performance-utils'; +import { toolbarEventStore } from '~core/notifications/event-tracking'; +import { CollapsedDroppedFrame, CollapsedItem } from './collapsed-event'; + +const useFlashManager = (events: NotificationEvent[]) => { + const prevEventsRef = useRef([]); + const [newEventIds, setNewEventIds] = useState>(new Set()); + const isInitialMount = useRef(true); + + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + prevEventsRef.current = events; + return; + } + + const currentIds = new Set(events.map((e) => e.id)); + const prevIds = new Set(prevEventsRef.current.map((e) => e.id)); + + const newIds = new Set(); + currentIds.forEach((id) => { + if (!prevIds.has(id)) { + newIds.add(id); + } + }); + + if (newIds.size > 0) { + setNewEventIds(newIds); + setTimeout(() => { + setNewEventIds(new Set()); + }, 2000); + } + + prevEventsRef.current = events; + }, [events]); + + return (id: string) => newEventIds.has(id); +}; + +const useFlash = ({ shouldFlash }: { shouldFlash: boolean }) => { + const [isFlashing, setIsFlashing] = useState(shouldFlash); + useEffect(() => { + if (shouldFlash) { + setIsFlashing(true); + const timer = setTimeout(() => { + setIsFlashing(false); + }, 1000); + return () => clearTimeout(timer); + } + }, [shouldFlash]); + + return isFlashing; +}; + +export const SlowdownHistoryItem = ({ + event, + shouldFlash, +}: { + event: NotificationEvent; + shouldFlash: boolean; +}) => { + const { notificationState, setNotificationState } = useNotificationsContext(); + + const severity = getEventSeverity(event); + + const isFlashing = useFlash({ shouldFlash }); + + switch (event.kind) { + case 'interaction': { + return ( + + ); + } + case 'dropped-frames': { + return ( + + ); + } + } +}; + +type CollapsedKeyboardInput = { + kind: 'collapsed-keyboard'; + events: Array; + timestamp: number; +}; + +type HistoryEvent = + | { + kind: 'single'; + event: NotificationEvent; + timestamp: number; + } + | CollapsedKeyboardInput + | CollapsedDroppedFrame; + +const collapseEvents = (events: Array) => { + const newEvents = events.reduce>((prev, curr) => { + const lastEvent = prev.at(-1); + if (!lastEvent) { + return [ + { + kind: 'single', + event: curr, + timestamp: curr.timestamp, + }, + ]; + } + + switch (lastEvent.kind) { + case 'collapsed-keyboard': { + if ( + curr.kind === 'interaction' && + curr.type === 'keyboard' && + // must be on the same semantic component, it would be ideal to compare on fiberId, but i digress + curr.componentPath.join('-') === + lastEvent.events[0].componentPath.join('-') + ) { + const eventsWithoutLast = prev.filter((e) => e !== lastEvent); + + return [ + ...eventsWithoutLast, + { + kind: 'collapsed-keyboard', + events: [...lastEvent.events, curr], + timestamp: Math.max( + ...[...lastEvent.events, curr].map((e) => e.timestamp), + ), + }, + ]; + } + + return [ + ...prev, + { + kind: 'single', + event: curr, + timestamp: curr.timestamp, + }, + ]; + } + case 'single': { + // if its a keyboard input on the same element + if ( + lastEvent.event.kind === 'interaction' && + lastEvent.event.type === 'keyboard' && + curr.kind === 'interaction' && + curr.type === 'keyboard' && + lastEvent.event.componentPath.join('-') === + curr.componentPath.join('-') + ) { + const eventsWithoutLast = prev.filter((e) => e !== lastEvent); + return [ + ...eventsWithoutLast, + { + kind: 'collapsed-keyboard', + events: [lastEvent.event, curr], + timestamp: Math.max(lastEvent.event.timestamp, curr.timestamp), + }, + ]; + } + if ( + lastEvent.event.kind === 'dropped-frames' && + curr.kind === 'dropped-frames' + ) { + const eventsWithoutLast = prev.filter((e) => e !== lastEvent); + + return [ + ...eventsWithoutLast, + { + kind: 'collapsed-frame-drops', + events: [lastEvent.event, curr], + timestamp: Math.max(lastEvent.event.timestamp, curr.timestamp), + }, + ]; + } + return [ + ...prev, + { + kind: 'single', + event: curr, + timestamp: curr.timestamp, + }, + ]; + } + case 'collapsed-frame-drops': { + if (curr.kind === 'dropped-frames') { + const eventsWithoutLast = prev.filter((e) => e !== lastEvent); + return [ + ...eventsWithoutLast, + { + kind: 'collapsed-frame-drops', + events: [...lastEvent.events, curr], + timestamp: Math.max( + ...[...lastEvent.events, curr].map((e) => e.timestamp), + ), + }, + ]; + } + return [ + ...prev, + { + kind: 'single', + event: curr, + timestamp: curr.timestamp, + }, + ]; + } + } + }, []); + return newEvents; +}; + +export const useLaggedEvents = (lagMs = 150) => { + const { notificationState } = useNotificationsContext(); + const [laggedEvents, setLaggedEvents] = useState(notificationState.events); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + setTimeout(() => { + setLaggedEvents(notificationState.events); + }, lagMs); + }, [notificationState.events]); + return [laggedEvents, setLaggedEvents] as const; +}; + +export const SlowdownHistory = () => { + const { notificationState, setNotificationState } = useNotificationsContext(); + const shouldFlash = useFlashManager(notificationState.events); + const [laggedEvents, setLaggedEvents] = useLaggedEvents(); + // this is to avoid a flicker from our overlapping events deduping logic. This should be handled downstream, but this simplifies logic for now + const collapsedEvents = collapseEvents(laggedEvents).toSorted( + (a, b) => b.timestamp - a.timestamp, + ); + + return ( +
+
+ History + { + toolbarEventStore.getState().actions.clear(); + setNotificationState((prev) => ({ + ...prev, + selectedEvent: null, + selectedFiber: null, + route: + prev.route === 'other-visualization' + ? 'other-visualization' + : 'render-visualization', + })); + setLaggedEvents([]); + }} + > + + + } + > +
+ Clear all events +
+
+
+
+ {collapsedEvents.length === 0 && ( +
+ No Events +
+ )} + {collapsedEvents.map((historyItem) => + iife(() => { + switch (historyItem.kind) { + case 'collapsed-keyboard': { + return ( + + ); + } + case 'single': { + return ( + + ); + } + case 'collapsed-frame-drops': { + return ( + + ); + } + } + }), + )} +
+
+ ); +}; diff --git a/packages/scan-react/src/web/views/toolbar/index.tsx b/packages/scan-react/src/web/views/toolbar/index.tsx new file mode 100644 index 00000000..ebfc1ed5 --- /dev/null +++ b/packages/scan-react/src/web/views/toolbar/index.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { + type LocalStorageOptions, + ReactScanInternals, + Store, +} from '~core/index'; +import { Icon } from '~web/components/icon'; +import { Toggle } from '~web/components/toggle'; +import { signalWidgetViews } from '~web/state'; +import { cn, readLocalStorage, saveLocalStorage } from '~web/utils/helpers'; +import { constant } from '~web/utils/preact/constant'; +import { + useComputed, + useSignalEffect, + useSignalValue, +} from '~web/utils/signals'; +import { FPSMeter } from '~web/widget/fps-meter'; +import { getEventSeverity } from '../notifications/data'; +import { Notification } from '../notifications/icons'; +import { useAppNotifications } from '../notifications/notifications'; + +export const Toolbar = constant(() => { + const events = useAppNotifications(); + const [laggedEvents, setLaggedEvents] = useState(events); + + useEffect(() => { + const timeout = setTimeout(() => { + setLaggedEvents(events); + // 500 + buffer to never see intermediary state + // todo: check if we still need this large of buffer + }, 500 + 100); + return () => { + clearTimeout(timeout); + }; + }, [events]); + + const inspectState = useSignalValue(Store.inspectState); + const isInspectActive = inspectState.kind === 'inspecting'; + const isInspectFocused = inspectState.kind === 'focused'; + + const widgetView = useSignalValue(signalWidgetViews); + const isPaused = useComputed( + () => !!ReactScanInternals.instrumentation?.isPaused.value, + ); + const showFPS = useComputed( + () => ReactScanInternals.options.value.showFPS, + ); + + const [seenEvents, setSeenEvents] = useState>([]); + + const onToggleInspect = useCallback(() => { + const currentState = Store.inspectState.value; + + switch (currentState.kind) { + case 'inspecting': { + signalWidgetViews.value = { + view: 'none', + }; + Store.inspectState.value = { + kind: 'inspect-off', + }; + return; + } + + case 'focused': { + signalWidgetViews.value = { + view: 'inspector', + }; + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: null, + }; + return; + } + // todo: auto select the root fibers first stateNode, and tell the user to select the element + case 'inspect-off': { + signalWidgetViews.value = { + view: 'none', + }; + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: null, + }; + return; + } + case 'uninitialized': { + return; + } + } + }, []); + + const onToggleActive = useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!ReactScanInternals.instrumentation) { + return; + } + // todo: set a single source of truth + const isPaused = !ReactScanInternals.instrumentation.isPaused.value; + ReactScanInternals.instrumentation.isPaused.value = isPaused; + const existingLocalStorageOptions = + readLocalStorage('react-scan-options'); + saveLocalStorage('react-scan-options', { + ...existingLocalStorageOptions, + enabled: !isPaused, + }); + }, + [], + ); + + useSignalEffect(() => { + const state = Store.inspectState.value; + if (state.kind === 'uninitialized') { + Store.inspectState.value = { + kind: 'inspect-off', + }; + } + }); + + let inspectIcon = null; + let inspectColor = '#999'; + + if (isInspectActive) { + inspectIcon = ; + inspectColor = '#8e61e3'; + } else if (isInspectFocused) { + inspectIcon = ; + inspectColor = '#8e61e3'; + } else { + inspectIcon = ; + inspectColor = '#999'; + } + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + if (signalWidgetViews.value.view !== 'notifications') { + return; + } + const ids = new Set(events.map((event) => event.id)); + setSeenEvents([...ids.values()]); + }, [events.length, widgetView.view]); + + return ( +
+
+ +
+ +
+ +
+ + + + {/* todo add back showFPS*/} + {showFPS && } +
+ ); +}); diff --git a/packages/scan-react/src/web/widget/fps-meter.tsx b/packages/scan-react/src/web/widget/fps-meter.tsx new file mode 100644 index 00000000..05b64a43 --- /dev/null +++ b/packages/scan-react/src/web/widget/fps-meter.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { getFPS } from "~core/instrumentation"; +import { cn } from "~web/utils/helpers"; + +const FpsMeterInner = ({ fps }: { fps: number }) => { + const getColor = (fps: number) => { + if (fps < 30) return "#EF4444"; + if (fps < 50) return "#F59E0B"; + return "rgb(214,132,245)"; + }; + + return ( +
+
+ {fps} +
+ + FPS + +
+ ); +}; + +export const FPSMeter = () => { + const [fps, setFps] = useState(null); + + useEffect(() => { + const intervalId = setInterval(() => { + setFps(getFPS()); + }, 200); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+ {/* fixme: default fps state*/} + {fps === null ? <>️ : } +
+ ); +}; diff --git a/packages/scan-react/src/web/widget/header.tsx b/packages/scan-react/src/web/widget/header.tsx new file mode 100644 index 00000000..a5e77d6a --- /dev/null +++ b/packages/scan-react/src/web/widget/header.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { Store } from "~core/index"; +import { Icon } from "~web/components/icon"; +import { COPY_FEEDBACK_DURATION_MS } from "~web/constants"; +import { useDelayedValue } from "~web/hooks/use-delayed-value"; +import { signalWidgetViews } from "~web/state"; +import { copyFocusedElement } from "~web/utils/copy-focused-element"; +import { hasNonEmptyTextSelection } from "~web/utils/has-non-empty-text-selection"; +import { cn } from "~web/utils/helpers"; +import { isInputLikeFocused } from "~web/utils/is-input-like-focused"; +import { isMac } from "~web/utils/is-mac"; +import { isUserReactGrabActive } from "~web/utils/is-user-react-grab-active"; +import { useSignalValue } from "~web/utils/signals"; +import { HeaderInspect } from "~web/views/inspector/header"; + +export const Header = () => { + const inspectState = useSignalValue(Store.inspectState); + const widgetViews = useSignalValue(signalWidgetViews); + const isInitialView = useDelayedValue(inspectState.kind === "focused", 150, 0); + const [isCopied, setIsCopied] = useState(false); + + const handleClose = () => { + signalWidgetViews.value = { + view: "none", + }; + Store.inspectState.value = { + kind: "inspect-off", + }; + }; + + const handleCopy = async () => { + const state = Store.inspectState.value; + if (state.kind !== "focused" || !state.focusedDomElement) return; + const didCopy = await copyFocusedElement(state.focusedDomElement); + if (!didCopy) return; + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + handleClose(); + }, COPY_FEEDBACK_DURATION_MS); + }; + + const refHandleCopy = useRef(handleCopy); + refHandleCopy.current = handleCopy; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const state = Store.inspectState.value; + if (state.kind !== "focused" || !state.focusedDomElement) return; + if (isUserReactGrabActive()) return; + if (!(event.metaKey || event.ctrlKey)) return; + if (event.shiftKey || event.altKey) return; + if (event.key !== "c" && event.code !== "KeyC") return; + if (isInputLikeFocused() || hasNonEmptyTextSelection()) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + void refHandleCopy.current(); + }; + + document.addEventListener("keydown", onKeyDown, { capture: true }); + return () => { + document.removeEventListener("keydown", onKeyDown, { capture: true }); + }; + }, []); + + const isHeaderIsNotifications = widgetViews.view === "notifications"; + + if (isHeaderIsNotifications) { + return null; + } + + const isFocused = inspectState.kind === "focused"; + const copyShortcutLabel = isMac() ? "⌘C" : "Ctrl+C"; + + return ( +
+
+
+ +
+
+ + {isFocused && ( + + )} + + +
+ ); +}; diff --git a/packages/scan-react/src/web/widget/helpers.ts b/packages/scan-react/src/web/widget/helpers.ts new file mode 100644 index 00000000..f0984adb --- /dev/null +++ b/packages/scan-react/src/web/widget/helpers.ts @@ -0,0 +1,428 @@ +import { MIN_SIZE } from '../constants'; +import { getSafeArea, type SafeAreaInsets } from '../utils/safe-area'; +import type { Corner, Position, ResizeHandleProps, Size } from './types'; + +class WindowDimensions { + maxWidth: number; + maxHeight: number; + + constructor( + public width: number, + public height: number, + public safeArea: SafeAreaInsets, + ) { + this.maxWidth = width - safeArea.left - safeArea.right; + this.maxHeight = height - safeArea.top - safeArea.bottom; + } + + rightEdge(width: number): number { + return this.width - width - this.safeArea.right; + } + + bottomEdge(height: number): number { + return this.height - height - this.safeArea.bottom; + } + + isFullWidth(width: number): boolean { + return width >= this.maxWidth; + } + + isFullHeight(height: number): boolean { + return height >= this.maxHeight; + } +} + +let cachedWindowDimensions: WindowDimensions | undefined; + +const safeAreaMatches = (a: SafeAreaInsets, b: SafeAreaInsets): boolean => + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left; + +export const getWindowDimensions = () => { + const currentWidth = window.innerWidth; + const currentHeight = window.innerHeight; + const currentSafeArea = getSafeArea(); + + if ( + cachedWindowDimensions && + cachedWindowDimensions.width === currentWidth && + cachedWindowDimensions.height === currentHeight && + safeAreaMatches(cachedWindowDimensions.safeArea, currentSafeArea) + ) { + return cachedWindowDimensions; + } + + cachedWindowDimensions = new WindowDimensions( + currentWidth, + currentHeight, + currentSafeArea, + ); + + return cachedWindowDimensions; +}; + +export const getOppositeCorner = ( + position: ResizeHandleProps['position'], + currentCorner: Corner, + isFullScreen: boolean, + isFullWidth?: boolean, + isFullHeight?: boolean, +): Corner => { + // For full screen mode + if (isFullScreen) { + if (position === 'top-left') return 'bottom-right'; + if (position === 'top-right') return 'bottom-left'; + if (position === 'bottom-left') return 'top-right'; + if (position === 'bottom-right') return 'top-left'; + + const [vertical, horizontal] = currentCorner.split('-'); + if (position === 'left') return `${vertical}-right` as Corner; + if (position === 'right') return `${vertical}-left` as Corner; + if (position === 'top') return `bottom-${horizontal}` as Corner; + if (position === 'bottom') return `top-${horizontal}` as Corner; + } + + // For full width mode + if (isFullWidth) { + if (position === 'left') + return `${currentCorner.split('-')[0]}-right` as Corner; + if (position === 'right') + return `${currentCorner.split('-')[0]}-left` as Corner; + } + + // For full height mode + if (isFullHeight) { + if (position === 'top') + return `bottom-${currentCorner.split('-')[1]}` as Corner; + if (position === 'bottom') + return `top-${currentCorner.split('-')[1]}` as Corner; + } + + return currentCorner; +}; + +export const calculatePosition = ( + corner: Corner, + width: number, + height: number, +): Position => { + const isRTL = getComputedStyle(document.body).direction === 'rtl'; + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const safeArea = getSafeArea(); + + // Check if widget is minimized + const isMinimized = width === MIN_SIZE.width; + + // Only bound dimensions if minimized + const effectiveWidth = isMinimized + ? width + : Math.min(width, windowWidth - safeArea.left - safeArea.right); + const effectiveHeight = isMinimized + ? height + : Math.min(height, windowHeight - safeArea.top - safeArea.bottom); + + // Calculate base positions + let x: number; + let y: number; + + let leftBound = safeArea.left; + let rightBound = windowWidth - effectiveWidth - safeArea.right; + let topBound = safeArea.top; + let bottomBound = windowHeight - effectiveHeight - safeArea.bottom; + + // In RTL the toolbar's containing block resolves to the right viewport + // edge (position: fixed with both left:0 and right:0 plus an explicit + // width pins to inline-end). translateX(negative) moves it leftward + // from that right anchor, so the *physical* right inset is `-safeArea.right` + // and the physical left inset is `-(windowWidth - width - safeArea.left)`. + // With symmetric SAFE_AREA these collapse to the original `-leftBound` / + // `-rightBound` values; asymmetric `safeArea` would otherwise apply the + // wrong edge to the wrong physical side. + const rtlRightCornerX = -safeArea.right; + const rtlLeftCornerX = -(windowWidth - effectiveWidth - safeArea.left); + + switch (corner) { + case 'top-right': + x = isRTL ? rtlRightCornerX : rightBound; + y = topBound; + break; + case 'bottom-right': + x = isRTL ? rtlRightCornerX : rightBound; + y = bottomBound; + break; + case 'bottom-left': + x = isRTL ? rtlLeftCornerX : leftBound; + y = bottomBound; + break; + case 'top-left': + x = isRTL ? rtlLeftCornerX : leftBound; + y = topBound; + break; + default: + x = leftBound; + y = topBound; + break; + } + + // Only ensure positions are within bounds if minimized + if (isMinimized) { + if (isRTL) { + x = Math.min( + rtlRightCornerX, + Math.max(x, rtlLeftCornerX), + ); + } else { + x = Math.max( + leftBound, + Math.min(x, rightBound), + ); + } + y = Math.max( + topBound, + Math.min(y, bottomBound), + ); + } + + return { x, y }; +}; + +const positionMatchesCorner = ( + position: ResizeHandleProps['position'], + corner: Corner, +): boolean => { + const [vertical, horizontal] = corner.split('-'); + return position !== vertical && position !== horizontal; +}; + +export const getHandleVisibility = ( + position: ResizeHandleProps['position'], + corner: Corner, + isFullWidth: boolean, + isFullHeight: boolean, +): boolean => { + if (isFullWidth && isFullHeight) { + return true; + } + + // Normal state + if (!isFullWidth && !isFullHeight) { + return positionMatchesCorner(position, corner); + } + + // Full width state + if (isFullWidth) { + return position !== corner.split('-')[0]; + } + + // Full height state + if (isFullHeight) { + return position !== corner.split('-')[1]; + } + + return false; +}; + +export const calculateBoundedSize = ( + currentSize: number, + delta: number, + isWidth: boolean, +): number => { + const min = isWidth ? MIN_SIZE.width : MIN_SIZE.initialHeight; + const max = isWidth + ? getWindowDimensions().maxWidth + : getWindowDimensions().maxHeight; + + const newSize = currentSize + delta; + return Math.min(Math.max(min, newSize), max); +}; + +export const calculateNewSizeAndPosition = ( + position: ResizeHandleProps['position'], + initialSize: Size, + initialPosition: Position, + deltaX: number, + deltaY: number, +): { newSize: Size; newPosition: Position } => { + const isRTL = getComputedStyle(document.body).direction === 'rtl'; + const safeArea = getSafeArea(); + + const maxWidth = window.innerWidth - safeArea.left - safeArea.right; + const maxHeight = window.innerHeight - safeArea.top - safeArea.bottom; + + let newWidth = initialSize.width; + let newHeight = initialSize.height; + let newX = initialPosition.x; + let newY = initialPosition.y; + + // horizontal resize for RTL + if (isRTL && position.includes('right')) { + // Check if we have enough space on the right + const availableWidth = -initialPosition.x + initialSize.width - safeArea.right; + const proposedWidth = Math.min(initialSize.width + deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + newX = initialPosition.x + (newWidth - initialSize.width); + } + if (isRTL && position.includes('left')) { + // Check if we have enough space on the left + const availableWidth = window.innerWidth - initialPosition.x - safeArea.left; + const proposedWidth = Math.min(initialSize.width - deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + } + // horizontal resize for LTR + if (!isRTL && position.includes('right')) { + // Check if we have enough space on the right + const availableWidth = window.innerWidth - initialPosition.x - safeArea.right; + const proposedWidth = Math.min(initialSize.width + deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + } + if (!isRTL && position.includes('left')) { + // Check if we have enough space on the left + const availableWidth = initialPosition.x + initialSize.width - safeArea.left; + const proposedWidth = Math.min(initialSize.width - deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + newX = initialPosition.x - (newWidth - initialSize.width); + } + + // vertical resize + if (position.includes('bottom')) { + // Check if we have enough space at the bottom + const availableHeight = window.innerHeight - initialPosition.y - safeArea.bottom; + const proposedHeight = Math.min( + initialSize.height + deltaY, + availableHeight, + ); + newHeight = Math.min( + maxHeight, + Math.max(MIN_SIZE.initialHeight, proposedHeight), + ); + } + if (position.includes('top')) { + // Check if we have enough space at the top + const availableHeight = initialPosition.y + initialSize.height - safeArea.top; + const proposedHeight = Math.min( + initialSize.height - deltaY, + availableHeight, + ); + newHeight = Math.min( + maxHeight, + Math.max(MIN_SIZE.initialHeight, proposedHeight), + ); + newY = initialPosition.y - (newHeight - initialSize.height); + } + + let leftBound = safeArea.left; + let rightBound = window.innerWidth - safeArea.right - newWidth; + let topBound = safeArea.top; + let bottomBound = window.innerHeight - safeArea.bottom - newHeight; + + // See `calculatePosition` for why RTL uses these values: the toolbar is + // right-anchored in RTL, so physical-edge insets need different bounds + // than the LTR `[leftBound, rightBound]` clamp. + const rtlRightCornerX = -safeArea.right; + const rtlLeftCornerX = -(window.innerWidth - newWidth - safeArea.left); + + // Ensure position stays within bounds + if (isRTL) { + newX = Math.min( + rtlRightCornerX, + Math.max(newX, rtlLeftCornerX), + ); + } else { + newX = Math.max( + leftBound, + Math.min(newX, rightBound), + ); + } + + newY = Math.max( + topBound, + Math.min(newY, bottomBound), + ); + + return { + newSize: { width: newWidth, height: newHeight }, + newPosition: { x: newX, y: newY }, + }; +}; + +export const getClosestCorner = (position: Position): Corner => { + const windowDims = getWindowDimensions(); + + const distances: Record = { + 'top-left': Math.hypot(position.x, position.y), + 'top-right': Math.hypot(windowDims.maxWidth - position.x, position.y), + 'bottom-left': Math.hypot(position.x, windowDims.maxHeight - position.y), + 'bottom-right': Math.hypot( + windowDims.maxWidth - position.x, + windowDims.maxHeight - position.y, + ), + }; + + let closest: Corner = 'top-left'; + + for (const key in distances) { + if (distances[key as Corner] < distances[closest]) { + closest = key as Corner; + } + } + + return closest; +}; + +// Helper to determine best corner based on cursor position, widget size, and movement +export const getBestCorner = ( + mouseX: number, + mouseY: number, + initialMouseX?: number, + initialMouseY?: number, + threshold = 100, +): Corner => { + const deltaX = initialMouseX !== undefined ? mouseX - initialMouseX : 0; + const deltaY = initialMouseY !== undefined ? mouseY - initialMouseY : 0; + + const windowCenterX = window.innerWidth / 2; + const windowCenterY = window.innerHeight / 2; + + // Determine movement direction + const movingRight = deltaX > threshold; + const movingLeft = deltaX < -threshold; + const movingDown = deltaY > threshold; + const movingUp = deltaY < -threshold; + + // If horizontal movement + if (movingRight || movingLeft) { + const isBottom = mouseY > windowCenterY; + return movingRight + ? isBottom + ? 'bottom-right' + : 'top-right' + : isBottom + ? 'bottom-left' + : 'top-left'; + } + + // If vertical movement + if (movingDown || movingUp) { + const isRight = mouseX > windowCenterX; + return movingDown + ? isRight + ? 'bottom-right' + : 'bottom-left' + : isRight + ? 'top-right' + : 'top-left'; + } + + // If no significant movement, use quadrant-based position + return mouseX > windowCenterX + ? mouseY > windowCenterY + ? 'bottom-right' + : 'top-right' + : mouseY > windowCenterY + ? 'bottom-left' + : 'top-left'; +}; diff --git a/packages/scan-react/src/web/widget/index.tsx b/packages/scan-react/src/web/widget/index.tsx new file mode 100644 index 00000000..3299a8d4 --- /dev/null +++ b/packages/scan-react/src/web/widget/index.tsx @@ -0,0 +1,767 @@ +import { + type CSSProperties, + createContext, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { Store, ReactScanInternals } from "~core/index"; +import { + cn, + saveLocalStorage, + removeLocalStorage, + readLocalStorage, +} from "~web/utils/helpers"; +import { Content } from "~web/views"; +import { ScanOverlay } from "~web/views/inspector/overlay"; +import { + LOCALSTORAGE_KEY, + LOCALSTORAGE_COLLAPSED_KEY, + MIN_SIZE, + LOCALSTORAGE_LAST_VIEW_KEY, + TOOLBAR_INTERACTIVE_SELECTOR, +} from "../constants"; +import { + getDefaultWidgetConfig, + signalRefWidget, + signalWidget, + signalWidgetViews, + updateDimensions, + type WidgetStates, +} from "../state"; +import { getSafeArea } from "../utils/safe-area"; +import { + calculateBoundedSize, + calculatePosition, + getBestCorner, +} from "./helpers"; +import { ResizeHandle } from "./resize-handle"; +import { signalWidgetCollapsed } from "~web/state"; +import { useSignalValue } from "~web/utils/signals"; +import { Icon } from "~web/components/icon"; +import { Corner } from "./types"; +import type { CollapsedPosition } from "./types"; + +const COLLAPSED_SIZE = { + horizontal: { width: 20, height: 48 }, + vertical: { width: 48, height: 20 }, +} as const; + +export const Widget = () => { + const refWidget = useRef(null); + const refShouldOpen = useRef(false); + + const refInitialMinimizedWidth = useRef(0); + const refInitialMinimizedHeight = useRef(0); + const refExpandingFromCollapsed = useRef(false); + + const updateWidgetPosition = useCallback((shouldSave = true) => { + if (!refWidget.current) return; + + const { corner } = signalWidget.value; + let newWidth: number; + let newHeight: number; + + if (signalWidgetCollapsed.value) { + const orientation = + signalWidgetCollapsed.value.orientation || "horizontal"; + const size = COLLAPSED_SIZE[orientation]; + newWidth = size.width; + newHeight = size.height; + } else if (refShouldOpen.current) { + const lastDims = signalWidget.value.lastDimensions; + newWidth = calculateBoundedSize(lastDims.width, 0, true); + newHeight = calculateBoundedSize(lastDims.height, 0, false); + + if (refExpandingFromCollapsed.current) { + refExpandingFromCollapsed.current = false; + } + } else { + newWidth = refInitialMinimizedWidth.current; + newHeight = refInitialMinimizedHeight.current; + } + + const newPosition = calculatePosition(corner, newWidth, newHeight); + + // When collapsed, override position so arrow is flush against the viewport edge. + let finalPosition = newPosition; + if (signalWidgetCollapsed.value) { + const { corner: collapsedCorner, orientation = "horizontal" } = + signalWidgetCollapsed.value; + const size = COLLAPSED_SIZE[orientation]; + const safeArea = getSafeArea(); + + switch (collapsedCorner) { + case "top-left": + finalPosition = + orientation === "horizontal" + ? { x: -1, y: safeArea.top } + : { x: safeArea.left, y: -1 }; + break; + case "bottom-left": + finalPosition = + orientation === "horizontal" + ? { x: -1, y: window.innerHeight - size.height - safeArea.bottom } + : { x: safeArea.left, y: window.innerHeight - size.height + 1 }; + break; + case "top-right": + finalPosition = + orientation === "horizontal" + ? { x: window.innerWidth - size.width + 1, y: safeArea.top } + : { x: window.innerWidth - size.width - safeArea.right, y: -1 }; + break; + case "bottom-right": + default: + finalPosition = + orientation === "horizontal" + ? { + x: window.innerWidth - size.width + 1, + y: window.innerHeight - size.height - safeArea.bottom, + } + : { + x: window.innerWidth - size.width - safeArea.right, + y: window.innerHeight - size.height + 1, + }; + break; + } + } + + const isTooSmall = + newWidth < MIN_SIZE.width || newHeight < MIN_SIZE.initialHeight; + const shouldPersist = shouldSave && !isTooSmall; + + const container = refWidget.current; + const containerStyle = container.style; + + let rafId: number | null = null; + const onTransitionEnd = () => { + updateDimensions(); + container.removeEventListener("transitionend", onTransitionEnd); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + container.addEventListener("transitionend", onTransitionEnd); + containerStyle.transition = "all 0.25s cubic-bezier(0, 0, 0.2, 1)"; + + rafId = requestAnimationFrame(() => { + containerStyle.width = `${newWidth}px`; + containerStyle.height = `${newHeight}px`; + containerStyle.transform = `translate3d(${finalPosition.x}px, ${finalPosition.y}px, 0)`; + rafId = null; + }); + + const safeArea = getSafeArea(); + const newDimensions = { + isFullWidth: newWidth >= window.innerWidth - safeArea.left - safeArea.right, + isFullHeight: newHeight >= window.innerHeight - safeArea.top - safeArea.bottom, + width: newWidth, + height: newHeight, + position: finalPosition, + }; + + signalWidget.value = { + corner, + dimensions: newDimensions, + lastDimensions: refShouldOpen + ? signalWidget.value.lastDimensions + : newWidth > refInitialMinimizedWidth.current + ? newDimensions + : signalWidget.value.lastDimensions, + componentsTree: signalWidget.value.componentsTree, + }; + + if (shouldPersist) { + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: signalWidget.value.corner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + componentsTree: signalWidget.value.componentsTree, + }); + } + + updateDimensions(); + }, []); + + const handleDrag = useCallback( + (e: React.PointerEvent) => { + const target = e.target as HTMLElement; + + // Skip drag on interactive/text-selectable surfaces so users can select + // prompt text, focus inputs, and click buttons normally. + if (target.closest(TOOLBAR_INTERACTIVE_SELECTOR)) { + return; + } + + e.preventDefault(); + + if (!refWidget.current) return; + + const container = refWidget.current; + const containerStyle = container.style; + const { dimensions } = signalWidget.value; + + const initialMouseX = e.clientX; + const initialMouseY = e.clientY; + + const initialX = dimensions.position.x; + const initialY = dimensions.position.y; + + let currentX = initialX; + let currentY = initialY; + let rafId: number | null = null; + let hasMoved = false; + let lastMouseX = initialMouseX; + let lastMouseY = initialMouseY; + + const handlePointerMove = (e: globalThis.PointerEvent) => { + if (rafId) return; + + hasMoved = true; + lastMouseX = e.clientX; + lastMouseY = e.clientY; + + rafId = requestAnimationFrame(() => { + const deltaX = lastMouseX - initialMouseX; + const deltaY = lastMouseY - initialMouseY; + + currentX = Number(initialX) + deltaX; + currentY = Number(initialY) + deltaY; + + containerStyle.transition = "none"; + containerStyle.transform = `translate3d(${currentX}px, ${currentY}px, 0)`; + + const widgetRight = currentX + dimensions.width; + const widgetBottom = currentY + dimensions.height; + + const outsideLeft = Math.max(0, -currentX); + const outsideRight = Math.max(0, widgetRight - window.innerWidth); + const outsideTop = Math.max(0, -currentY); + const outsideBottom = Math.max(0, widgetBottom - window.innerHeight); + + const horizontalOutside = Math.min( + dimensions.width, + outsideLeft + outsideRight + ); + const verticalOutside = Math.min( + dimensions.height, + outsideTop + outsideBottom + ); + const areaOutside = + horizontalOutside * dimensions.height + + verticalOutside * dimensions.width - + horizontalOutside * verticalOutside; + const totalArea = dimensions.width * dimensions.height; + + // todo: delete this doesn't do anything + let shouldCollapse = areaOutside > totalArea * 0.35; + + if (!shouldCollapse && ReactScanInternals.options.value.showFPS) { + const fpsRight = currentX + dimensions.width; + const fpsLeft = fpsRight - 100; + + const fpsFullyOutside = + fpsRight <= 0 || + fpsLeft >= window.innerWidth || + currentY + dimensions.height <= 0 || + currentY >= window.innerHeight; + + shouldCollapse = fpsFullyOutside; + } + + if (shouldCollapse) { + const widgetCenterX = currentX + dimensions.width / 2; + const widgetCenterY = currentY + dimensions.height / 2; + const screenCenterX = window.innerWidth / 2; + const screenCenterY = window.innerHeight / 2; + + let targetCorner: Corner; + if (widgetCenterX < screenCenterX) { + targetCorner = + widgetCenterY < screenCenterY ? "top-left" : "bottom-left"; + } else { + targetCorner = + widgetCenterY < screenCenterY ? "top-right" : "bottom-right"; + } + + let orientation: "horizontal" | "vertical"; + const horizontalOverflow = Math.max(outsideLeft, outsideRight); + const verticalOverflow = Math.max(outsideTop, outsideBottom); + + orientation = + horizontalOverflow > verticalOverflow ? "horizontal" : "vertical"; + + signalWidget.value = { + ...signalWidget.value, + corner: targetCorner, + lastDimensions: { + ...dimensions, + position: calculatePosition( + targetCorner, + dimensions.width, + dimensions.height + ), + }, + }; + + const collapsedPosition: CollapsedPosition = { + corner: targetCorner, + orientation, + }; + + signalWidgetCollapsed.value = collapsedPosition; + saveLocalStorage(LOCALSTORAGE_COLLAPSED_KEY, collapsedPosition); + saveLocalStorage(LOCALSTORAGE_KEY, signalWidget.value); + updateWidgetPosition(false); + + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerEnd); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + rafId = null; + }); + }; + + const handlePointerEnd = () => { + if (!container) return; + + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerEnd); + + // Calculate total movement distance + const totalDeltaX = Math.abs(lastMouseX - initialMouseX); + const totalDeltaY = Math.abs(lastMouseY - initialMouseY); + const totalMovement = Math.sqrt( + totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY + ); + + // Only consider it a move if we moved more than 60 pixels + if (!hasMoved || totalMovement < 60) return; + + const newCorner = getBestCorner( + lastMouseX, + lastMouseY, + initialMouseX, + initialMouseY, + Store.inspectState.value.kind === "focused" ? 80 : 40 + ); + + if (newCorner === signalWidget.value.corner) { + containerStyle.transition = + "transform 0.25s cubic-bezier(0, 0, 0.2, 1)"; + const currentPosition = signalWidget.value.dimensions.position; + requestAnimationFrame(() => { + containerStyle.transform = `translate3d(${currentPosition.x}px, ${currentPosition.y}px, 0)`; + }); + + return; + } + + const snappedPosition = calculatePosition( + newCorner, + dimensions.width, + dimensions.height + ); + + if (currentX === initialX && currentY === initialY) return; + + const onTransitionEnd = () => { + containerStyle.transition = "none"; + updateDimensions(); + container.removeEventListener("transitionend", onTransitionEnd); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + container.addEventListener("transitionend", onTransitionEnd); + containerStyle.transition = + "transform 0.25s cubic-bezier(0, 0, 0.2, 1)"; + + requestAnimationFrame(() => { + containerStyle.transform = `translate3d(${snappedPosition.x}px, ${snappedPosition.y}px, 0)`; + }); + + signalWidget.value = { + corner: newCorner, + dimensions: { + isFullWidth: dimensions.isFullWidth, + isFullHeight: dimensions.isFullHeight, + width: dimensions.width, + height: dimensions.height, + position: snappedPosition, + }, + lastDimensions: signalWidget.value.lastDimensions, + componentsTree: signalWidget.value.componentsTree, + }; + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + componentsTree: signalWidget.value.componentsTree, + }); + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerEnd); + }, + [] + ); + + const handleCollapsedDrag = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + + if (!refWidget.current || !signalWidgetCollapsed.value) return; + + const { corner: collapsedCorner, orientation = "horizontal" } = + signalWidgetCollapsed.value; + + const initialMouseX = e.clientX; + const initialMouseY = e.clientY; + + let rafId: number | null = null; + let hasExpanded = false; + + const DRAG_THRESHOLD = 50; + + const handlePointerMove = (e: globalThis.PointerEvent) => { + if (hasExpanded || rafId) return; + + const deltaX = e.clientX - initialMouseX; + const deltaY = e.clientY - initialMouseY; + + let shouldExpand = false; + + if (orientation === "horizontal") { + if (collapsedCorner.endsWith("left") && deltaX > DRAG_THRESHOLD) { + shouldExpand = true; + } else if ( + collapsedCorner.endsWith("right") && + deltaX < -DRAG_THRESHOLD + ) { + shouldExpand = true; + } + } else { + if (collapsedCorner.startsWith("top") && deltaY > DRAG_THRESHOLD) { + shouldExpand = true; + } else if ( + collapsedCorner.startsWith("bottom") && + deltaY < -DRAG_THRESHOLD + ) { + shouldExpand = true; + } + } + + if (shouldExpand) { + hasExpanded = true; + + signalWidgetCollapsed.value = null; + saveLocalStorage(LOCALSTORAGE_COLLAPSED_KEY, null); + + if (refInitialMinimizedWidth.current === 0 && refWidget.current) { + requestAnimationFrame(() => { + if (refWidget.current) { + refWidget.current.style.width = "min-content"; + const naturalWidth = refWidget.current.offsetWidth; + refInitialMinimizedWidth.current = naturalWidth || 300; + + const lastDims = signalWidget.value.lastDimensions; + const targetWidth = calculateBoundedSize( + lastDims.width, + 0, + true + ); + const targetHeight = calculateBoundedSize( + lastDims.height, + 0, + false + ); + + let newX = e.clientX - targetWidth / 2; + let newY = e.clientY - targetHeight / 2; + + const safeArea = getSafeArea(); + newX = Math.max( + safeArea.left, + Math.min(newX, window.innerWidth - targetWidth - safeArea.right) + ); + newY = Math.max( + safeArea.top, + Math.min(newY, window.innerHeight - targetHeight - safeArea.bottom) + ); + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + ...signalWidget.value.dimensions, + position: { x: newX, y: newY }, + }, + }; + + updateWidgetPosition(true); + + const savedView = readLocalStorage( + LOCALSTORAGE_LAST_VIEW_KEY + ); + signalWidgetViews.value = savedView || { view: "none" }; + + setTimeout(() => { + if (refWidget.current) { + const dragEvent = new PointerEvent("pointerdown", { + clientX: e.clientX, + clientY: e.clientY, + pointerId: e.pointerId, + bubbles: true, + }); + refWidget.current.dispatchEvent(dragEvent); + } + }, 100); + } + }); + } else { + updateWidgetPosition(true); + const savedView = readLocalStorage( + LOCALSTORAGE_LAST_VIEW_KEY + ); + signalWidgetViews.value = savedView || { view: "none" }; + } + + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerEnd); + } + }; + + const handlePointerEnd = () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerEnd); + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerEnd); + }, + [] + ); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!refWidget.current) return; + + removeLocalStorage(LOCALSTORAGE_LAST_VIEW_KEY); + + if (!signalWidgetCollapsed.value) { + refWidget.current.style.width = "min-content"; + refInitialMinimizedHeight.current = 36; // height of the header + refInitialMinimizedWidth.current = refWidget.current.offsetWidth; + } else { + refInitialMinimizedHeight.current = 36; + refInitialMinimizedWidth.current = 0; + } + + const safeArea = getSafeArea(); + refWidget.current.style.maxWidth = `calc(100vw - ${safeArea.left + safeArea.right}px)`; + refWidget.current.style.maxHeight = `calc(100vh - ${safeArea.top + safeArea.bottom}px)`; + + updateWidgetPosition(); + + if ( + Store.inspectState.value.kind !== "focused" && + !signalWidgetCollapsed.value && + !refExpandingFromCollapsed.current + ) { + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: refInitialMinimizedWidth.current, + height: refInitialMinimizedHeight.current, + position: signalWidget.value.dimensions.position, + }, + }; + } + + signalRefWidget.value = refWidget.current; + + const unsubscribeSignalWidget = signalWidget.subscribe((widget) => { + if (!refWidget.current) return; + + const { x, y } = widget.dimensions.position; + const { width, height } = widget.dimensions; + const container = refWidget.current; + + requestAnimationFrame(() => { + container.style.transform = `translate3d(${x}px, ${y}px, 0)`; + container.style.width = `${width}px`; + container.style.height = `${height}px`; + }); + }); + + const unsubscribeSignalWidgetViews = signalWidgetViews.subscribe( + (state) => { + refShouldOpen.current = state.view !== "none"; + updateWidgetPosition(); + + if (!signalWidgetCollapsed.value) { + if (state.view !== "none") { + saveLocalStorage(LOCALSTORAGE_LAST_VIEW_KEY, state); + } else { + removeLocalStorage(LOCALSTORAGE_LAST_VIEW_KEY); + } + } + } + ); + + const unsubscribeStoreInspectState = Store.inspectState.subscribe( + (state) => { + refShouldOpen.current = state.kind === "focused"; + updateWidgetPosition(); + } + ); + + const handleWindowResize = () => { + updateWidgetPosition(true); + }; + + window.addEventListener("resize", handleWindowResize, { passive: true }); + + return () => { + window.removeEventListener("resize", handleWindowResize); + unsubscribeSignalWidgetViews(); + unsubscribeStoreInspectState(); + unsubscribeSignalWidget(); + + saveLocalStorage(LOCALSTORAGE_KEY, { + ...getDefaultWidgetConfig(), + corner: signalWidget.value.corner, + }); + }; + }, []); + + // i don't want to put the ref in state, so this is the solution to force context to propagate it + const [_, setTriggerRender] = useState(false); + useEffect(() => { + setTriggerRender(true); + }, []); + + const isCollapsed = useSignalValue(signalWidgetCollapsed); + + let arrowRotationClass = ""; + if (isCollapsed) { + const { orientation = "horizontal", corner } = isCollapsed; + if (orientation === "horizontal") { + arrowRotationClass = corner?.endsWith("right") ? "rotate-180" : ""; + } else { + arrowRotationClass = corner?.startsWith("bottom") + ? "-rotate-90" + : "rotate-90"; + } + } + + return ( + <> + + +
{ + const { orientation = "horizontal", corner } = isCollapsed; + if (orientation === "horizontal") { + return corner?.endsWith("right") + ? "rounded-tl-lg rounded-bl-lg shadow-lg" + : "rounded-tr-lg rounded-br-lg shadow-lg"; + } else { + return corner?.startsWith("bottom") + ? "rounded-tl-lg rounded-tr-lg shadow-lg" + : "rounded-bl-lg rounded-br-lg shadow-lg"; + } + })() + : "rounded-lg shadow-lg", + "flex flex-col", + "font-mono text-[13px]", + "user-select-none", + "opacity-0", + isCollapsed ? "cursor-pointer" : "cursor-move", + "z-[124124124124]", + "animate-fade-in animation-duration-300 animation-delay-300", + "will-change-transform", + "[touch-action:none]" + )} + style={{ WebkitAppRegion: "no-drag" } as CSSProperties} + > + {/* this entire feature is vibe coded don't think too hard about the code its probably very non coherent */} + {isCollapsed ? ( + + ) : ( + <> + + + + + + + )} +
+
+ + ); +}; + +export const ToolbarElementContext = createContext(null); diff --git a/packages/scan-react/src/web/widget/resize-handle.tsx b/packages/scan-react/src/web/widget/resize-handle.tsx new file mode 100644 index 00000000..40648c61 --- /dev/null +++ b/packages/scan-react/src/web/widget/resize-handle.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Store } from '~core/index'; +import { Icon } from '~web/components/icon'; +import { + LOCALSTORAGE_KEY, + MIN_CONTAINER_WIDTH, + MIN_SIZE, +} from '~web/constants'; +import { + signalRefWidget, + signalWidget, + signalWidgetViews, +} from '~web/state'; +import { cn, saveLocalStorage } from '~web/utils/helpers'; +import { + calculateNewSizeAndPosition, + calculatePosition, + getClosestCorner, + getHandleVisibility, + getOppositeCorner, + getWindowDimensions, +} from './helpers'; +import type { Corner, ResizeHandleProps } from './types'; + +export const ResizeHandle = ({ position }: ResizeHandleProps) => { + const refContainer = useRef(null); + + const prevWidth = useRef(null); + const prevHeight = useRef(null); + const prevCorner = useRef(null); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const container = refContainer.current; + if (!container) return; + + const updateVisibility = () => { + container.classList.remove('pointer-events-none'); + + const isFocused = Store.inspectState.value.kind === 'focused'; + const shouldShow = signalWidgetViews.value.view !== 'none'; + const isVisible = + (isFocused || shouldShow) && + getHandleVisibility( + position, + signalWidget.value.corner, + signalWidget.value.dimensions.isFullWidth, + signalWidget.value.dimensions.isFullHeight, + ); + + if (isVisible) { + container.classList.remove( + 'hidden', + 'pointer-events-none', + 'opacity-0', + ); + } else { + container.classList.add('hidden', 'pointer-events-none', 'opacity-0'); + } + }; + + const unsubscribeSignalWidget = signalWidget.subscribe((state) => { + if ( + prevWidth.current !== null && + prevHeight.current !== null && + prevCorner.current !== null && + state.dimensions.width === prevWidth.current && + state.dimensions.height === prevHeight.current && + state.corner === prevCorner.current + ) { + return; + } + + updateVisibility(); + + prevWidth.current = state.dimensions.width; + prevHeight.current = state.dimensions.height; + prevCorner.current = state.corner; + }); + + const unsubscribeInspectState = Store.inspectState.subscribe(() => { + updateVisibility(); + }); + + return () => { + unsubscribeSignalWidget(); + unsubscribeInspectState(); + prevWidth.current = null; + prevHeight.current = null; + prevCorner.current = null; + }; + }, []); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + const handleResize = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const widget = signalRefWidget.value; + if (!widget) return; + + const containerStyle = widget.style; + const { dimensions } = signalWidget.value; + const initialX = e.clientX; + const initialY = e.clientY; + + const initialWidth = dimensions.width; + const initialHeight = dimensions.height; + const initialPosition = dimensions.position; + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + ...dimensions, + isFullWidth: false, + isFullHeight: false, + width: initialWidth, + height: initialHeight, + position: initialPosition, + }, + }; + + let rafId: number | null = null; + + const handlePointerMove = (e: PointerEvent) => { + if (rafId) return; + + containerStyle.transition = 'none'; + + rafId = requestAnimationFrame(() => { + const { newSize, newPosition } = calculateNewSizeAndPosition( + position, + { width: initialWidth, height: initialHeight }, + initialPosition, + e.clientX - initialX, + e.clientY - initialY, + ); + + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + containerStyle.width = `${newSize.width}px`; + containerStyle.height = `${newSize.height}px`; + + // Adjust components tree width when widget is resized + const maxTreeWidth = Math.floor(newSize.width - (MIN_CONTAINER_WIDTH / 2)); + const currentTreeWidth = signalWidget.value.componentsTree.width; + const newTreeWidth = Math.min( + maxTreeWidth, + Math.max(MIN_CONTAINER_WIDTH, currentTreeWidth), + ); + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: newSize.width, + height: newSize.height, + position: newPosition, + }, + componentsTree: { + ...signalWidget.value.componentsTree, + width: newTreeWidth, + }, + }; + + rafId = null; + }); + }; + + const handlePointerUp = () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + + const { dimensions, corner } = signalWidget.value; + const windowDims = getWindowDimensions(); + const isCurrentFullWidth = windowDims.isFullWidth(dimensions.width); + const isCurrentFullHeight = windowDims.isFullHeight(dimensions.height); + const isFullScreen = isCurrentFullWidth && isCurrentFullHeight; + + let newCorner = corner; + if (isFullScreen || isCurrentFullWidth || isCurrentFullHeight) { + newCorner = getClosestCorner(dimensions.position); + } + + const newPosition = calculatePosition( + newCorner, + dimensions.width, + dimensions.height, + ); + + const onTransitionEnd = () => { + widget.removeEventListener('transitionend', onTransitionEnd); + }; + + widget.addEventListener('transitionend', onTransitionEnd); + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + + signalWidget.value = { + ...signalWidget.value, + corner: newCorner, + dimensions: { + isFullWidth: isCurrentFullWidth, + isFullHeight: isCurrentFullHeight, + width: dimensions.width, + height: dimensions.height, + position: newPosition, + }, + lastDimensions: { + isFullWidth: isCurrentFullWidth, + isFullHeight: isCurrentFullHeight, + width: dimensions.width, + height: dimensions.height, + position: newPosition, + }, + }; + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + componentsTree: signalWidget.value.componentsTree, + }); + }; + + document.addEventListener('pointermove', handlePointerMove, { + passive: true, + }); + document.addEventListener('pointerup', handlePointerUp); + }, + [], + ); + + // oxlint-disable-next-line react-hooks/exhaustive-deps + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const widget = signalRefWidget.value; + if (!widget) return; + + const containerStyle = widget.style; + const { dimensions, corner } = signalWidget.value; + const windowDims = getWindowDimensions(); + + const isCurrentFullWidth = windowDims.isFullWidth(dimensions.width); + const isCurrentFullHeight = windowDims.isFullHeight(dimensions.height); + const isFullScreen = isCurrentFullWidth && isCurrentFullHeight; + const isPartiallyMaximized = + (isCurrentFullWidth || isCurrentFullHeight) && !isFullScreen; + + let newWidth = dimensions.width; + let newHeight = dimensions.height; + const newCorner = getOppositeCorner( + position, + corner, + isFullScreen, + isCurrentFullWidth, + isCurrentFullHeight, + ); + + if (position === 'left' || position === 'right') { + newWidth = isCurrentFullWidth ? dimensions.width : windowDims.maxWidth; + if (isPartiallyMaximized) { + newWidth = isCurrentFullWidth ? MIN_SIZE.width : windowDims.maxWidth; + } + } else { + newHeight = isCurrentFullHeight + ? dimensions.height + : windowDims.maxHeight; + if (isPartiallyMaximized) { + newHeight = isCurrentFullHeight + ? MIN_SIZE.initialHeight + : windowDims.maxHeight; + } + } + + if (isFullScreen) { + if (position === 'left' || position === 'right') { + newWidth = MIN_SIZE.width; + } else { + newHeight = MIN_SIZE.initialHeight; + } + } + + const newPosition = calculatePosition(newCorner, newWidth, newHeight); + const newDimensions = { + isFullWidth: windowDims.isFullWidth(newWidth), + isFullHeight: windowDims.isFullHeight(newHeight), + width: newWidth, + height: newHeight, + position: newPosition, + }; + + // Adjust components tree width when widget is resized + const maxTreeWidth = Math.floor(newWidth - MIN_SIZE.width / 2); + const currentTreeWidth = signalWidget.value.componentsTree.width; + const defaultWidth = Math.floor(newWidth * 0.3); // Use 30% of window width as default + + const newTreeWidth = isCurrentFullWidth + ? MIN_CONTAINER_WIDTH + : (position === 'left' || position === 'right') && !isCurrentFullWidth + ? Math.min(maxTreeWidth, Math.max(MIN_CONTAINER_WIDTH, defaultWidth)) + : Math.min( + maxTreeWidth, + Math.max(MIN_CONTAINER_WIDTH, currentTreeWidth), + ); + + requestAnimationFrame(() => { + signalWidget.value = { + corner: newCorner, + dimensions: newDimensions, + lastDimensions: dimensions, + componentsTree: { + ...signalWidget.value.componentsTree, + width: newTreeWidth, + }, + }; + + containerStyle.transition = 'all 0.25s cubic-bezier(0, 0, 0.2, 1)'; + containerStyle.width = `${newWidth}px`; + containerStyle.height = `${newHeight}px`; + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + }); + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: newDimensions, + lastDimensions: dimensions, + componentsTree: { + ...signalWidget.value.componentsTree, + width: newTreeWidth, + }, + }); + }, + [], + ); + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/scan-react/src/web/widget/types.ts b/packages/scan-react/src/web/widget/types.ts new file mode 100644 index 00000000..4b6a40c8 --- /dev/null +++ b/packages/scan-react/src/web/widget/types.ts @@ -0,0 +1,46 @@ +export interface Position { + x: number; + y: number; +} + +export interface Size { + width: number; + height: number; +} + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +export type CollapsedPosition = { + corner: Corner; + orientation: "horizontal" | "vertical"; +}; + +export interface ResizeHandleProps { + position: Corner | "top" | "bottom" | "left" | "right"; +} + +export interface WidgetDimensions { + isFullWidth: boolean; + isFullHeight: boolean; + width: number; + height: number; + position: Position; +} + +export interface ComponentsTreeConfig { + width: number; +} + +export interface WidgetConfig { + corner: Corner; + dimensions: WidgetDimensions; + lastDimensions: WidgetDimensions; + componentsTree: ComponentsTreeConfig; +} + +export interface WidgetSettings { + corner: Corner; + dimensions: WidgetDimensions; + lastDimensions: WidgetDimensions; + componentsTree: ComponentsTreeConfig; +} diff --git a/packages/scan-react/tsconfig.json b/packages/scan-react/tsconfig.json new file mode 100644 index 00000000..295a0d34 --- /dev/null +++ b/packages/scan-react/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": false, + "sourceMap": true, + "noEmit": true, + // The React port uses the React automatic JSX runtime instead of Preact's. + "jsx": "react-jsx", + "jsxImportSource": "react", + "types": ["node", "react", "react-dom"], + "paths": { + // UI lives here; the profiling engine ("core") is re-used from the + // original `react-scan` package source — it is framework-agnostic state. + "~web/*": ["src/web/*"], + "~core/*": ["../scan/src/core/*"], + // The re-used engine uses baseUrl-rooted `src/...` imports; resolve them + // against the original react-scan source so core does not need editing. + "src/*": ["../scan/src/*"] + } + }, + // Pull in the upstream global type augmentations (window.__REACT_SCAN__, + // RequestInit.priority, etc.) and the `*.css` ambient module that the + // re-used `~core` engine relies on. + "include": ["src", "global.d.ts", "../scan/src/types.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab454ac1..5c2149d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,37 @@ importers: specifier: ^3.0.0 version: 3.0.0 + packages/scan-react: + dependencies: + bippy: + specifier: 0.5.39 + version: 0.5.39(react@19.2.5) + react-doctor: + specifier: latest + version: 0.2.10(eslint@10.4.0(jiti@2.6.1))(oxlint-tsgolint@0.22.0)(vite-plus@0.1.20(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(publint@0.3.18)(terser@5.46.2)(tsx@4.20.3)(typescript@6.0.3)(vite@6.3.5(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.20.3)(yaml@2.9.0))(yaml@2.9.0)) + react-grab: + specifier: latest + version: 0.1.37(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: 19.2.5 + version: 19.2.5 + react-dom: + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) + react-scan: + specifier: workspace:* + version: link:../scan + typescript: + specifier: '*' + version: 6.0.3 + packages/vite-plugin-react-scan: dependencies: '@babel/core': @@ -7050,6 +7081,10 @@ snapshots: dependencies: react: 19.0.0 + bippy@0.5.41(react@19.2.5): + dependencies: + react: 19.2.5 + bluebird@3.7.2: {} boolbase@1.0.0: {} @@ -8677,6 +8712,13 @@ snapshots: optionalDependencies: react: 19.0.0 + react-grab@0.1.37(react@19.2.5): + dependencies: + '@react-grab/cli': 0.1.37 + bippy: 0.5.41(react@19.2.5) + optionalDependencies: + react: 19.2.5 + react-router-dom@6.30.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@remix-run/router': 1.23.0