diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..c7054e40 --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +- do not write comments that are self explanatory \ No newline at end of file diff --git a/.github/assets/logo.svg b/.github/assets/logo.svg index 5030b05a..bc119cb6 100644 --- a/.github/assets/logo.svg +++ b/.github/assets/logo.svg @@ -1,13 +1,13 @@ - + - - + + - - - - + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json index a4309ac4..457ceaee 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ }, "css.lint.unknownAtRules": "ignore", "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" @@ -24,5 +24,9 @@ "typescript.tsdk": "node_modules/typescript/lib", "[css]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "cSpell.words": ["highlightable"] } diff --git a/@popover.tsx b/@popover.tsx new file mode 100644 index 00000000..e69de29b diff --git a/biome.json b/biome.json index dd696cec..c0a7e9b0 100644 --- a/biome.json +++ b/biome.json @@ -23,7 +23,7 @@ "enabled": true }, "linter": { - "enabled": true, + "enabled": false, "rules": { "recommended": true, "correctness": { diff --git a/examples/sierpinski/public/logo.svg b/examples/sierpinski/public/logo.svg index 5030b05a..bc119cb6 100644 --- a/examples/sierpinski/public/logo.svg +++ b/examples/sierpinski/public/logo.svg @@ -1,13 +1,13 @@ - + - - + + - - - - + + + + diff --git a/packages/extension/public/icon/icon.svg b/packages/extension/public/icon/icon.svg index 5030b05a..bc119cb6 100644 --- a/packages/extension/public/icon/icon.svg +++ b/packages/extension/public/icon/icon.svg @@ -1,13 +1,13 @@ - + - - + + - - - - + + + + diff --git a/packages/extension/public/icon/logo-animated.svg b/packages/extension/public/icon/logo-animated.svg index da533b01..5583d8c5 100644 --- a/packages/extension/public/icon/logo-animated.svg +++ b/packages/extension/public/icon/logo-animated.svg @@ -1,7 +1,7 @@ - - - + + + - + - - + + - - - - + + + + diff --git a/packages/scan/package.json b/packages/scan/package.json index a18bc65c..a2c8b835 100644 --- a/packages/scan/package.json +++ b/packages/scan/package.json @@ -1,14 +1,8 @@ { "name": "react-scan", - "version": "0.1.3", + "version": "0.1.4", "description": "Scan your React app for renders", - "keywords": [ - "react", - "react-scan", - "react scan", - "render", - "performance" - ], + "keywords": ["react", "react-scan", "react scan", "render", "performance"], "homepage": "https://react-scan.million.dev", "bugs": { "url": "/~https://github.com/aidenybai/react-scan/issues" @@ -23,6 +17,21 @@ "email": "aiden@million.dev", "url": "https://million.dev" }, + "scripts": { + "build": "npm run build:css && NODE_ENV=production tsup", + "postbuild": "pnpm copy-astro && node ../../scripts/version-warning.mjs", + "build:copy": "npm run build:css && NODE_ENV=production tsup && cat dist/auto.global.js | pbcopy", + "copy-astro": "cp -R src/core/monitor/params/astro dist/core/monitor/params", + "dev:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css --watch", + "dev:tsup": "NODE_ENV=development tsup --watch", + "dev": "pnpm copy-astro && npm-run-all --parallel dev:css dev:tsup", + "build:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css", + "pack": "npm version patch && pnpm build && npm pack", + "pack:bump": "bun scripts/bump-version.js && nr pack && echo $(pwd)/react-scan-$(node -p \"require('./package.json').version\").tgz | pbcopy", + "prettier": "prettier --config .prettierrc.mjs -w src", + "publint": "publint", + "test": "vitest" + }, "exports": { "./package.json": "./package.json", "./monitoring": { @@ -172,27 +181,17 @@ "types": "dist/index.d.ts", "typesVersions": { "*": { - "monitoring": [ - "./dist/core/monitor/index.d.ts" - ], - "monitoring/next": [ - "./dist/core/monitor/params/next.d.ts" - ], + "monitoring": ["./dist/core/monitor/index.d.ts"], + "monitoring/next": ["./dist/core/monitor/params/next.d.ts"], "monitoring/react-router-legacy": [ "./dist/core/monitor/params/react-router-v5.d.ts" ], "monitoring/react-router": [ "./dist/core/monitor/params/react-router-v6.d.ts" ], - "monitoring/remix": [ - "./dist/core/monitor/params/remix.d.ts" - ], - "monitoring/astro": [ - "./dist/core/monitor/params/astro/index.ts" - ], - "react-component-name/vite": [ - "./dist/react-component-name/vite.d.ts" - ], + "monitoring/remix": ["./dist/core/monitor/params/remix.d.ts"], + "monitoring/astro": ["./dist/core/monitor/params/astro/index.ts"], + "react-component-name/vite": ["./dist/react-component-name/vite.d.ts"], "react-component-name/webpack": [ "./dist/react-component-name/webpack.d.ts" ], @@ -208,38 +207,12 @@ "react-component-name/rollup": [ "./dist/react-component-name/rollup.d.ts" ], - "react-component-name/astro": [ - "./dist/react-component-name/astro.d.ts" - ], - "react-component-name/loader": [ - "./dist/react-component-name/loader.d.ts" - ] + "react-component-name/astro": ["./dist/react-component-name/astro.d.ts"], + "react-component-name/loader": ["./dist/react-component-name/loader.d.ts"] } }, "bin": "bin/cli.js", - "files": [ - "dist", - "bin", - "package.json", - "README.md", - "LICENSE", - "auto.d.ts" - ], - "scripts": { - "build": "npm run build:css && NODE_ENV=production tsup", - "postbuild": "pnpm copy-astro && node ../../scripts/version-warning.mjs", - "build:copy": "npm run build:css && NODE_ENV=production tsup && cat dist/auto.global.js | pbcopy", - "copy-astro": "cp -R src/core/monitor/params/astro dist/core/monitor/params", - "dev:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css --watch", - "dev:tsup": "NODE_ENV=development tsup --watch", - "dev": "pnpm copy-astro && npm-run-all --parallel dev:css dev:tsup", - "build:css": "postcss ./src/web/assets/css/styles.tailwind.css -o ./src/web/assets/css/styles.css", - "pack": "npm version patch && pnpm build && npm pack", - "pack:bump": "bun scripts/bump-version.js && nr pack && echo $(pwd)/react-scan-$(node -p \"require('./package.json').version\").tgz | pbcopy", - "prettier": "prettier --config .prettierrc.mjs -w src", - "publint": "publint", - "test": "vitest" - }, + "files": ["dist", "bin", "package.json", "README.md", "LICENSE", "auto.d.ts"], "dependencies": { "@babel/core": "^7.26.0", "@babel/generator": "^7.26.2", diff --git a/packages/scan/postcss.rem2px.mjs b/packages/scan/postcss.rem2px.mjs index db6b01b9..9aac4dc0 100644 --- a/packages/scan/postcss.rem2px.mjs +++ b/packages/scan/postcss.rem2px.mjs @@ -1,7 +1,6 @@ const remToPx = (options = {}) => { const baseValue = options.baseValue || 16; - // Improved regex that handles all rem cases including negatives const remRegex = /(? { diff --git a/packages/scan/src/auto.ts b/packages/scan/src/auto.ts index b143d3c0..d89cf743 100644 --- a/packages/scan/src/auto.ts +++ b/packages/scan/src/auto.ts @@ -1,11 +1,13 @@ -import { scan } from './index'; -import { init } from './install-hook'; +import { scan } from "./index"; +import { init } from "./install-hook"; init(); -if (typeof window !== 'undefined') { - scan(); +if (typeof window !== "undefined") { + scan({ + dangerouslyForceRunInProduction: true, + }); window.reactScan = scan; } -export * from './core'; +export * from "./core"; diff --git a/packages/scan/src/cli.mts b/packages/scan/src/cli.mts index ec5619fc..5549d6f2 100644 --- a/packages/scan/src/cli.mts +++ b/packages/scan/src/cli.mts @@ -1,9 +1,9 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { cancel, confirm, intro, isCancel, spinner } from '@clack/prompts'; -import { bgMagenta, dim, red } from 'kleur'; -import mri from 'mri'; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { cancel, confirm, intro, isCancel, spinner } from "@clack/prompts"; +import { bgMagenta, dim, red } from "kleur"; +import mri from "mri"; import { type Browser, type BrowserContext, @@ -11,15 +11,15 @@ import { devices, firefox, webkit, -} from 'playwright'; +} from "playwright"; const truncateString = (str: string, maxLength: number) => { let result = str - .replace('http://', '') - .replace('https://', '') - .replace('www.', ''); + .replace("http://", "") + .replace("https://", "") + .replace("www.", ""); - if (result.endsWith('/')) { + if (result.endsWith("/")) { result = result.slice(0, -1); } @@ -39,42 +39,42 @@ const inferValidURL = (maybeURL: string) => { try { return new URL(`https://${maybeURL}`).href; } catch { - return 'about:blank'; + return "about:blank"; } } }; const getBrowserDetails = async (browserType: string) => { switch (browserType) { - case 'firefox': - return { browserType: firefox, channel: undefined, name: 'firefox' }; - case 'webkit': - return { browserType: webkit, channel: undefined, name: 'webkit' }; + case "firefox": + return { browserType: firefox, channel: undefined, name: "firefox" }; + case "webkit": + return { browserType: webkit, channel: undefined, name: "webkit" }; default: - return { browserType: chromium, channel: 'chrome', name: 'chrome' }; + return { browserType: chromium, channel: "chrome", name: "chrome" }; } }; const userAgentStrings = [ - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.2227.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.3497.92 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36', + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.2227.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.3497.92 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", ]; const applyStealthScripts = async (context: BrowserContext) => { await context.addInitScript(() => { // Override the navigator.webdriver property - Object.defineProperty(navigator, 'webdriver', { + Object.defineProperty(navigator, "webdriver", { get: () => undefined, }); // Mock languages and plugins to mimic a real browser - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'], + Object.defineProperty(navigator, "languages", { + get: () => ["en-US", "en"], }); - Object.defineProperty(navigator, 'plugins', { + Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5], }); @@ -91,14 +91,14 @@ const applyStealthScripts = async (context: BrowserContext) => { win.__PW_inspect = undefined; // Redefine the headless property - Object.defineProperty(navigator, 'headless', { + Object.defineProperty(navigator, "headless", { get: () => false, }); // Override the permissions API const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => - parameters.name === 'notifications' + parameters.name === "notifications" ? Promise.resolve({ state: Notification.permission, } as PermissionStatus) @@ -107,7 +107,7 @@ const applyStealthScripts = async (context: BrowserContext) => { }; const init = async () => { - intro(`${bgMagenta('[·]')} React Scan`); + intro(`${bgMagenta("[·]")} React Scan`); const args = mri(process.argv.slice(2)); let browser: Browser | undefined; @@ -120,14 +120,14 @@ const init = async () => { ...device, acceptDownloads: true, viewport: null, - locale: 'en-US', - timezoneId: 'America/New_York', + locale: "en-US", + timezoneId: "America/New_York", args: [ - '--enable-webgl', - '--use-gl=swiftshader', - '--enable-accelerated-2d-canvas', - '--disable-blink-features=AutomationControlled', - '--disable-web-security', + "--enable-webgl", + "--use-gl=swiftshader", + "--enable-accelerated-2d-canvas", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", ], userAgent: userAgentStrings[Math.floor(Math.random() * userAgentStrings.length)], @@ -152,10 +152,10 @@ const init = async () => { const runInstall = () => { confirm({ message: - 'No drivers found. Install Playwright Chromium driver to continue?', + "No drivers found. Install Playwright Chromium driver to continue?", }).then((shouldInstall) => { if (isCancel(shouldInstall)) { - cancel('Operation cancelled.'); + cancel("Operation cancelled."); process.exit(0); } if (!shouldInstall) { @@ -163,20 +163,20 @@ const init = async () => { } const installProcess = spawn( - 'npx', - ['playwright@latest', 'install', 'chromium'], - { stdio: 'inherit' }, + "npx", + ["playwright@latest", "install", "chromium"], + { stdio: "inherit" } ); - installProcess.on('close', (code) => { + installProcess.on("close", (code) => { if (!code) resolve(); else reject( - new Error(`Installation process exited with code ${code}`), + new Error(`Installation process exited with code ${code}`) ); }); - installProcess.on('error', reject); + installProcess.on("error", reject); }); }; @@ -189,7 +189,7 @@ const init = async () => { browser = await chromium.launch({ headless: false }); } catch { cancel( - 'No browser could be launched. Please run `npx playwright install` to install browser drivers.', + "No browser could be launched. Please run `npx playwright install` to install browser drivers." ); } } @@ -197,7 +197,7 @@ const init = async () => { if (!browser) { cancel( - 'No browser could be launched. Please run `npx playwright install` to install browser drivers.', + "No browser could be launched. Please run `npx playwright install` to install browser drivers." ); return; } @@ -225,24 +225,26 @@ const init = async () => { })();`, }); - const page = await context.newPage(); - - const scriptContent = fs.readFileSync( - path.resolve(__dirname, './auto.global.js'), - 'utf8', + const scriptContent = await fs.readFile( + path.resolve(__dirname, "./auto.global.js"), + "utf8" ); - const inputUrl = args._[0] || 'about:blank'; + // Add React Scan script at context level so it's available for all pages + await context.addInitScript({ + content: `window.hideIntro = true;${scriptContent}\n//# sourceURL=react-scan.js`, + }); + + const page = await context.newPage(); + + const inputUrl = args._[0] || "about:blank"; const urlString = inferValidURL(inputUrl); await page.goto(urlString); - await page.waitForLoadState('load'); - await page.waitForTimeout(500); - await page.addScriptTag({ - content: `${scriptContent}\n//# sourceURL=react-scan.js`, - }); + await page.waitForLoadState("load"); + await page.waitForTimeout(500); const pollReport = async () => { if (page.url() !== currentURL) return; @@ -261,7 +263,7 @@ const init = async () => { if (!Object.keys(reportData).length) return; // biome-ignore lint/suspicious/noConsole: Intended debug output - console.log('REACT_SCAN_REPORT', count); + console.log("REACT_SCAN_REPORT", count); }); }; @@ -275,13 +277,13 @@ const init = async () => { if (interval) clearInterval(interval); currentURL = url; const truncatedURL = truncateString(url, 35); - currentSpinner?.stop(`${truncatedURL}${count ? ` (×${count})` : ''}`); + currentSpinner?.stop(`${truncatedURL}${count ? ` (×${count})` : ""}`); currentSpinner = spinner(); currentSpinner.start(dim(`Scanning: ${truncatedURL}`)); count = 0; try { - await page.waitForLoadState('load'); + await page.waitForLoadState("load"); await page.waitForTimeout(500); const hasReactScan = await page.evaluate(() => { @@ -289,18 +291,13 @@ const init = async () => { }); if (!hasReactScan) { - await page.addScriptTag({ - content: scriptContent, - }); + // Script is already registered at context level, just reload + await page.reload(); + return; } await page.waitForTimeout(100); - await page.evaluate(() => { - if (typeof globalThis.reactScan !== 'function') return; - globalThis.reactScan({ report: true }); - }); - interval = setInterval(() => { pollReport().catch(() => {}); }, 1000); @@ -311,18 +308,18 @@ const init = async () => { await inject(urlString); - page.on('framenavigated', async (frame) => { + page.on("framenavigated", async (frame) => { if (frame !== page.mainFrame()) return; const url = frame.url(); inject(url); }); - page.on('console', async (msg) => { + page.on("console", async (msg) => { const text = msg.text(); - if (!text.startsWith('REACT_SCAN_REPORT')) { + if (!text.startsWith("REACT_SCAN_REPORT")) { return; } - const reportDataString = text.replace('REACT_SCAN_REPORT', '').trim(); + const reportDataString = text.replace("REACT_SCAN_REPORT", "").trim(); try { count = Number.parseInt(reportDataString, 10); } catch { @@ -332,7 +329,7 @@ const init = async () => { const truncatedURL = truncateString(currentURL, 50); if (currentSpinner) { currentSpinner.message( - dim(`Scanning: ${truncatedURL}${count ? ` (×${count})` : ''}`), + dim(`Scanning: ${truncatedURL}${count ? ` (×${count})` : ""}`) ); } }); diff --git a/packages/scan/src/core/create-store.ts b/packages/scan/src/core/create-store.ts new file mode 100644 index 00000000..7111831d --- /dev/null +++ b/packages/scan/src/core/create-store.ts @@ -0,0 +1,142 @@ +/** + * Adapted from zustand v5.0.3 + * /~https://github.com/pmndrs/zustand + */ +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; + }; +} + +export type ExtractState = S extends { getState: () => infer T } ? T : never; + +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 }; + +export interface StoreMutators {} +export type StoreMutatorIdentifier = keyof StoreMutators; + +type CreateStore = { + ( + initializer: StateCreator, + ): Mutate, Mos>; + + (): ( + initializer: StateCreator, + ) => Mutate, Mos>; +}; + +type CreateStoreImpl = < + T, + Mos extends [StoreMutatorIdentifier, unknown][] = [], +>( + 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) + | ((state: TState) => any), + listener?: (selectedState: any, prevSelectedState: any) => void, + ) => { + let selector: ((state: TState) => any) | undefined; + let actualListener: (state: any, prevState: any) => void; + + if (listener) { + // Selector subscription case + 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)); + return api as any; +}; + +export const createStore = ((createState) => + createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore; diff --git a/packages/scan/src/core/heatmap-overlay.ts b/packages/scan/src/core/heatmap-overlay.ts new file mode 100644 index 00000000..fdcb86d5 --- /dev/null +++ b/packages/scan/src/core/heatmap-overlay.ts @@ -0,0 +1,237 @@ +import { signal } from "@preact/signals"; +import { iife } from "./notifications/performance-utils"; + +export interface HeatmapOverlay { + boundingRect: DOMRect; + ms: number; + name: string; +} + +type CanvasState = { + rects: HeatmapOverlay[]; + highlightedName: string | null; + opacity: number; + isAnimating: boolean; + phase: "idle" | "fading-out" | "fading-in"; + pendingHighlight: string | null; +}; + +export let highlightCanvas: HTMLCanvasElement | null = null; +export let highlightCtx: CanvasRenderingContext2D | null = null; + +let animationFrame: number | null = null; + +const initialState: CanvasState = { + rects: [], + highlightedName: null, + opacity: 0, + isAnimating: false, + phase: "idle", + pendingHighlight: null, +}; + +let state: CanvasState = { ...initialState }; + +export type TransitionHighlightState = { + kind: "transition"; + transitionTo: { + name: string; + rects: Array; + alpha: number; + }; + current: { + name: string; + rects: Array; + alpha: number; + } | null; +}; +type HighlightState = + | TransitionHighlightState + | { + kind: "move-out"; + current: { + name: string; + rects: Array; + alpha: number; + }; + } + | { + kind: "idle"; + current: { + name: string; + rects: Array; + } | null; + }; + +export const HighlightStore = signal({ + kind: "idle", + current: null, +}); + +let currFrame: ReturnType | null = null; +export const drawHighlights = () => { + if (currFrame) { + cancelAnimationFrame(currFrame); + } + currFrame = requestAnimationFrame(() => { + if (!highlightCanvas || !highlightCtx) { + return; + } + + highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height); + + const color = "hsl(271, 76%, 53%)"; + const state = HighlightStore.value; + const { alpha, current } = iife(() => { + switch (state.kind) { + case "transition": { + const current = + state.current?.alpha && state.current.alpha > 0 + ? state.current + : state.transitionTo; + return { + alpha: current ? current.alpha : 0, + current, + }; + } + case "move-out": { + return { alpha: state.current?.alpha ?? 0, current: state.current }; + } + case "idle": { + return { alpha: 1, current: state.current }; + } + } + // exhaustive check + state satisfies never; + }); + + current?.rects.forEach((rect) => { + if (!highlightCtx) { + // typescript cant tell this closure is synchronous/non-escaping + return; + } + highlightCtx.shadowColor = color; + highlightCtx.shadowBlur = 6; + highlightCtx.strokeStyle = color; + highlightCtx.lineWidth = 2; + + highlightCtx.globalAlpha = alpha; + + highlightCtx.beginPath(); + highlightCtx.rect(rect.left, rect.top, rect.width, rect.height); + highlightCtx.stroke(); + + highlightCtx.shadowBlur = 0; + highlightCtx.beginPath(); + highlightCtx.rect(rect.left, rect.top, rect.width, rect.height); + highlightCtx.stroke(); + }); + + switch (state.kind) { + case "move-out": { + if (state.current.alpha === 0) { + HighlightStore.value = { + kind: "idle", + current: null, + }; + return; + } + if (state.current.alpha <= 0.01) { + state.current.alpha = 0; + } + state.current.alpha = Math.max(0, state.current.alpha - 0.03); + drawHighlights(); + return; + } + case "transition": { + if (state.current && state.current.alpha > 0) { + state.current.alpha = Math.max(0, state.current.alpha - 0.03); + drawHighlights(); + return; + } + + // invariant, state.current.alpha === 0 + if (state.transitionTo.alpha === 1) { + HighlightStore.value = { + kind: "idle", + current: state.transitionTo, + }; + return; + } + + // intentionally linear + state.transitionTo.alpha = Math.min(state.transitionTo.alpha + 0.03, 1); + + drawHighlights(); + } + case "idle": { + // no-op + return; + } + } + }); +}; + +let handleResizeListener: (() => void) | null = null; +export const createHighlightCanvas = (root: HTMLElement) => { + highlightCanvas = document.createElement("canvas"); + highlightCtx = highlightCanvas.getContext("2d", { alpha: true }); + if (!highlightCtx) return null; + + const dpr = window.devicePixelRatio || 1; + const rect = root.getBoundingClientRect(); + + highlightCanvas.style.width = `${rect.width}px`; + highlightCanvas.style.height = `${rect.height}px`; + highlightCanvas.width = rect.width * dpr; + highlightCanvas.height = rect.height * dpr; + highlightCanvas.style.position = "fixed"; + highlightCanvas.style.left = "0"; + highlightCanvas.style.top = "0"; + highlightCanvas.style.pointerEvents = "none"; + highlightCanvas.style.zIndex = "999999999999"; + + highlightCtx.scale(dpr, dpr); + + root.appendChild(highlightCanvas); + + if (handleResizeListener) { + window.removeEventListener("resize", handleResizeListener); + } + + const handleResize = () => { + if (!highlightCanvas || !highlightCtx) return; + const dpr = window.devicePixelRatio || 1; + const rect = root.getBoundingClientRect(); + + highlightCanvas.style.width = `${rect.width}px`; + highlightCanvas.style.height = `${rect.height}px`; + highlightCanvas.width = rect.width * dpr; + highlightCanvas.height = rect.height * dpr; + highlightCtx.scale(dpr, dpr); + + drawHighlights(); + }; + handleResizeListener = handleResize; + + window.addEventListener("resize", handleResize); + + HighlightStore.subscribe(() => { + requestAnimationFrame(() => { + drawHighlights(); + }); + }); +}; + +export function cleanup() { + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + if (highlightCanvas?.parentNode) { + highlightCanvas.parentNode.removeChild(highlightCanvas); + } + highlightCanvas = null; + highlightCtx = null; + state = { ...initialState }; +} diff --git a/packages/scan/src/core/index.ts b/packages/scan/src/core/index.ts index 71a540a4..241cad0c 100644 --- a/packages/scan/src/core/index.ts +++ b/packages/scan/src/core/index.ts @@ -1,29 +1,38 @@ -import { type Signal, signal } from '@preact/signals'; +import { type Signal, signal } from "@preact/signals"; import { type Fiber, detectReactBuildType, getRDTHook, getType, isInstrumentationActive, -} from 'bippy'; -import type { ComponentType } from 'preact'; -import type { ReactNode } from 'preact/compat'; -import type { RenderData } from 'src/core/utils'; +} from "bippy"; +import type { ComponentType } from "preact"; +import type { ReactNode } from "preact/compat"; +import type { RenderData } from "src/core/utils"; // import { initReactScanOverlay } from '~web/overlay'; -import { initReactScanInstrumentation } from 'src/new-outlines'; -import styles from '~web/assets/css/styles.css'; -import { ICONS } from '~web/assets/svgs/svgs'; -import type { States } from '~web/components/inspector/utils'; -import { createToolbar, scriptLevelToolbar } from '~web/toolbar'; -import { readLocalStorage, saveLocalStorage } from '~web/utils/helpers'; -import type { Outline } from '~web/utils/outline'; +import { + getCanvasEl, + hasStopped, + initReactScanInstrumentation, + startReportInterval, +} from "src/new-outlines"; +import styles from "~web/assets/css/styles.css"; +import { ICONS } from "~web/assets/svgs/svgs"; +import { createToolbar, scriptLevelToolbar } from "~web/toolbar"; +import { readLocalStorage, saveLocalStorage } from "~web/utils/helpers"; +import { logIntro } from "~web/utils/log"; +import type { Outline } from "~web/utils/outline"; +import type { States } from "~web/views/inspector/utils"; import type { ChangeReason, Render, createInstrumentation, -} from './instrumentation'; -import type { InternalInteraction } from './monitor/types'; -import type { getSession } from './monitor/utils'; +} from "./instrumentation"; +import { InternalInteraction } from "./monitor/types"; +import { getSession } from "./monitor/utils"; +// import type { InternalInteraction } from "./monitor/types"; +// import type { getSession } from "./monitor/utils"; +import { startTimingTracking } from "./notifications/event-tracking"; let rootContainer: HTMLDivElement | null = null; let shadowRoot: ShadowRoot | null = null; @@ -41,24 +50,29 @@ const initRootContainer = (): RootContainer => { return { rootContainer, shadowRoot }; } - rootContainer = document.createElement('div'); - rootContainer.id = 'react-scan-root'; + rootContainer = document.createElement("div"); + rootContainer.id = "react-scan-root"; - shadowRoot = rootContainer.attachShadow({ mode: 'open' }); + shadowRoot = rootContainer.attachShadow({ mode: "open" }); const fragment = document.createDocumentFragment(); - const cssStyles = document.createElement('style'); + const cssStyles = document.createElement("style"); cssStyles.textContent = styles; const iconSprite = new DOMParser().parseFromString( ICONS, - 'image/svg+xml', + "image/svg+xml" ).documentElement; + shadowRoot.appendChild(iconSprite); + const root = document.createElement("div"); + root.id = "react-scan-toolbar-root"; + root.className = "absolute z-2147483647"; - fragment.appendChild(iconSprite); fragment.appendChild(cssStyles); + fragment.appendChild(root); + shadowRoot.appendChild(fragment); document.documentElement.appendChild(rootContainer); @@ -148,7 +162,7 @@ export interface Options { * * @default "fast" */ - animationSpeed?: 'slow' | 'fast' | 'off'; + animationSpeed?: "slow" | "fast" | "off"; /** * Track unnecessary renders, and mark their outlines gray when detected @@ -161,6 +175,13 @@ export interface Options { */ trackUnnecessaryRenders?: boolean; + /** + * Should the FPS meter show in the toolbar + * + * @default true + */ + showFPS?: boolean; + onCommitStart?: () => void; onRender?: (fiber: Fiber, renders: Array) => void; onCommitFinish?: () => void; @@ -170,12 +191,12 @@ export interface Options { export type MonitoringOptions = Pick< Options, - | 'enabled' - | 'onCommitStart' - | 'onCommitFinish' - | 'onPaintStart' - | 'onPaintFinish' - | 'onRender' + | "enabled" + | "onCommitStart" + | "onCommitFinish" + | "onPaintStart" + | "onPaintFinish" + | "onRender" >; interface Monitor { @@ -187,6 +208,9 @@ interface Monitor { apiKey: string | null; commit: string | null; branch: string | null; + interactionListeningForRenders: + | ((fiber: Fiber, renders: Array) => void) + | null; } export interface StoreType { @@ -198,6 +222,7 @@ export interface StoreType { fiberRoots: WeakSet; reportData: Map; legacyReportData: Map; + changesListeners: Map>; } export type OutlineKey = `${string}-${string}`; @@ -206,7 +231,7 @@ export interface Internals { instrumentation: ReturnType | null; componentAllowList: WeakMap, Options> | null; options: Signal; - scheduledOutlines: Map; // we clear t,his nearly immediately, so no concern of mem leak on the fiber + scheduledOutlines: Map; // we clear this nearly immediately, so no concern of mem leak on the fiber // outlines at the same coordinates always get merged together, so we pre-compute the merge ahead of time when aggregating in activeOutlines activeOutlines: Map; // we re-use the outline object on the scheduled outline onRender: ((fiber: Fiber, renders: Array) => void) | null; @@ -225,7 +250,7 @@ export type ClassComponentStateChange = { value: unknown; prevValue?: unknown; count?: number | undefined; - name: 'state'; + name: "state"; }; export type StateChange = @@ -261,16 +286,17 @@ export type ChangesListener = (changes: ChangesPayload) => void; export const Store: StoreType = { wasDetailsOpen: signal(true), isInIframe: signal( - typeof window !== 'undefined' && window.self !== window.top, + typeof window !== "undefined" && window.self !== window.top ), inspectState: signal({ - kind: 'uninitialized', + kind: "uninitialized", }), monitor: signal(null), fiberRoots: new Set(), reportData: new Map(), legacyReportData: new Map(), lastReportTime: signal(0), + changesListeners: new Map>(), }; export const ReactScanInternals: Internals = { @@ -285,10 +311,11 @@ export const ReactScanInternals: Internals = { renderCountThreshold: 0, report: undefined, alwaysShowLabels: false, - animationSpeed: 'fast', + animationSpeed: "fast", dangerouslyForceRunInProduction: false, smoothlyAnimateOutlines: true, trackUnnecessaryRenders: false, + showFPS: true, }), onRender: null, scheduledOutlines: new Map(), @@ -298,11 +325,11 @@ export const ReactScanInternals: Internals = { export type LocalStorageOptions = Omit< Options, - | 'onCommitStart' - | 'onRender' - | 'onCommitFinish' - | 'onPaintStart' - | 'onPaintFinish' + | "onCommitStart" + | "onRender" + | "onCommitFinish" + | "onPaintStart" + | "onPaintFinish" >; function isOptionKey(key: string): key is keyof Options { @@ -318,14 +345,14 @@ const validateOptions = (options: Partial): Partial => { const value = options[key]; switch (key) { - case 'enabled': + case "enabled": // case 'includeChildren': - case 'log': - case 'showToolbar': + case "log": + case "showToolbar": // case 'report': // case 'alwaysShowLabels': - case 'dangerouslyForceRunInProduction': - if (typeof value !== 'boolean') { + case "dangerouslyForceRunInProduction": + if (typeof value !== "boolean") { errors.push(`- ${key} must be a boolean. Got "${value}"`); } else { validOptions[key] = value; @@ -339,42 +366,42 @@ const validateOptions = (options: Partial): Partial => { // validOptions[key] = value as number; // } // break; - case 'animationSpeed': - if (!['slow', 'fast', 'off'].includes(value as string)) { + case "animationSpeed": + if (!["slow", "fast", "off"].includes(value as string)) { errors.push( - `- Invalid animation speed "${value}". Using default "fast"`, + `- Invalid animation speed "${value}". Using default "fast"` ); } else { - validOptions[key] = value as 'slow' | 'fast' | 'off'; + validOptions[key] = value as "slow" | "fast" | "off"; } break; - case 'onCommitStart': - if (typeof value !== 'function') { + case "onCommitStart": + if (typeof value !== "function") { errors.push(`- ${key} must be a function. Got "${value}"`); } else { validOptions.onCommitStart = value as () => void; } break; - case 'onCommitFinish': - if (typeof value !== 'function') { + case "onCommitFinish": + if (typeof value !== "function") { errors.push(`- ${key} must be a function. Got "${value}"`); } else { validOptions.onCommitFinish = value as () => void; } break; - case 'onRender': - if (typeof value !== 'function') { + case "onRender": + if (typeof value !== "function") { errors.push(`- ${key} must be a function. Got "${value}"`); } else { validOptions.onRender = value as ( fiber: Fiber, - renders: Array, + renders: Array ) => void; } break; - case 'onPaintStart': - case 'onPaintFinish': - if (typeof value !== 'function') { + case "onPaintStart": + case "onPaintFinish": + if (typeof value !== "function") { errors.push(`- ${key} must be a function. Got "${value}"`); } else { validOptions[key] = value as (outlines: Array) => void; @@ -397,7 +424,7 @@ const validateOptions = (options: Partial): Partial => { if (errors.length > 0) { // biome-ignore lint/suspicious/noConsole: Intended debug output - console.warn(`[React Scan] Invalid options:\n${errors.join('\n')}`); + console.warn(`[React Scan] Invalid options:\n${errors.join("\n")}`); } return validOptions; @@ -428,18 +455,18 @@ export const setOptions = (userOptions: Partial) => { }; const { instrumentation } = ReactScanInternals; - if (instrumentation && 'enabled' in validOptions) { + if (instrumentation && "enabled" in validOptions) { instrumentation.isPaused.value = validOptions.enabled === false; } ReactScanInternals.options.value = newOptions; const existingLocalStorageOptions = - readLocalStorage('react-scan-options'); + readLocalStorage("react-scan-options"); // we always want to persist the local storage option specifically for enabled to avoid annoying the user // if the user doesn't have a toolbar we fallback to the true options because there wouldn't be a way to // revert the local storage value - saveLocalStorage('react-scan-options', { + saveLocalStorage("react-scan-options", { ...newOptions, enabled: newOptions.showToolbar ? (existingLocalStorageOptions?.enabled ?? newOptions.enabled ?? true) @@ -461,7 +488,7 @@ export const getIsProduction = () => { rdtHook ??= getRDTHook(); for (const renderer of rdtHook.renderers.values()) { const buildType = detectReactBuildType(renderer); - if (buildType === 'production') { + if (buildType === "production") { isProduction = true; } } @@ -469,19 +496,24 @@ export const getIsProduction = () => { }; export const start = () => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } - if ( - getIsProduction() && - !ReactScanInternals.options.value.dangerouslyForceRunInProduction - ) { - return; - } + Store.monitor.value = { + pendingRequests: 0, + interactions: [], + session: new Promise((res) => res(null)), + url: null, + route: null, + apiKey: null, + commit: null, + branch: null, + interactionListeningForRenders: null, + }; const localStorageOptions = - readLocalStorage('react-scan-options'); + readLocalStorage("react-scan-options"); if (localStorageOptions) { const { enabled } = localStorageOptions; @@ -497,16 +529,56 @@ export const start = () => { const options = getOptions(); - idempotent_createToolbar(!!options.value.showToolbar); - initReactScanInstrumentation(); - const isUsedInBrowserExtension = typeof window !== 'undefined'; + initReactScanInstrumentation({ + onActive: () => { + const rdtHook = getRDTHook(); + + if (hasStopped()) return; + + for (const renderer of rdtHook.renderers.values()) { + const buildType = detectReactBuildType(renderer); + if (buildType === "production") { + isProduction = true; + } + } + + if ( + isProduction && + !ReactScanInternals.options.value.dangerouslyForceRunInProduction + ) { + setOptions({ enabled: false, showToolbar: false }); + // biome-ignore lint/suspicious/noConsole: Intended debug output + console.warn( + "[React Scan] Running in production mode is not recommended.\n" + + "If you really need this, set dangerouslyForceRunInProduction: true in options." + ); + return; + } + + startTimingTracking(); + + idempotent_createToolbar(!!options.value.showToolbar); + + const host = getCanvasEl(); + if (host) { + document.documentElement.appendChild(host); + } + globalThis.__REACT_SCAN__ = { + ReactScanInternals, + }; + startReportInterval(); + logIntro(); + }, + }); + + const isUsedInBrowserExtension = typeof window !== "undefined"; if (!Store.monitor.value && !isUsedInBrowserExtension) { setTimeout(() => { if (isInstrumentationActive()) return; // biome-ignore lint/suspicious/noConsole: Intended debug output console.error( - '[React Scan] Failed to load. Must import React Scan before React runs.', + "[React Scan] Failed to load. Must import React Scan before React runs." ); }, 5000); } @@ -560,7 +632,7 @@ export const useScan = (options: Options = {}) => { export const onRender = ( type: unknown, - _onRender: (fiber: Fiber, renders: Array) => void, + _onRender: (fiber: Fiber, renders: Array) => void ) => { const prevOnRender = ReactScanInternals.onRender; ReactScanInternals.onRender = (fiber, renders) => { @@ -576,7 +648,7 @@ export const ignoredProps = new WeakSet< >(); export const ignoreScan = (node: ReactNode) => { - if (node && typeof node === 'object') { + if (node && typeof node === "object") { ignoredProps.add(node); } }; diff --git a/packages/scan/src/core/instrumentation.ts b/packages/scan/src/core/instrumentation.ts index 8399d166..ffd8c80e 100644 --- a/packages/scan/src/core/instrumentation.ts +++ b/packages/scan/src/core/instrumentation.ts @@ -1,4 +1,4 @@ -import { type Signal, signal } from '@preact/signals'; +import { type Signal, signal } from "@preact/signals"; import { ClassComponentTag, type Fiber, @@ -18,40 +18,62 @@ import { traverseContexts, traverseProps, traverseRenderedFibers, -} from 'bippy'; -import { isValidElement } from 'preact'; -import { isEqual } from '~core/utils'; +} from "bippy"; +import { isValidElement } from "preact"; +import { isEqual } from "~core/utils"; +import { + RENDER_PHASE_STRING_TO_ENUM, + type RenderPhase, +} from "~web/utils/outline"; import { collectContextChanges, collectPropsChanges, collectStateChanges, -} from '~web/components/inspector/timeline/utils'; -import { - RENDER_PHASE_STRING_TO_ENUM, - type RenderPhase, -} from '~web/utils/outline'; +} from "~web/views/inspector/timeline/utils"; import { type Change, type ContextChange, ReactScanInternals, type StateChange, Store, -} from './index'; +} from "./index"; let fps = 0; let lastTime = performance.now(); let frameCount = 0; let initedFps = false; -const updateFPS = () => { +let fpsListeners: Array<(fps: number) => void> = []; + +export const listenToFps = (listener: (fps: number) => void) => { + fpsListeners.push(listener); + return () => { + fpsListeners = fpsListeners.filter( + (currListener) => currListener !== listener + ); + }; +}; + +const updateFPS = (onChange?: (fps: number) => void) => { frameCount++; const now = performance.now(); - if (now - lastTime >= 1000) { - fps = frameCount; + const timeSinceLastUpdate = now - lastTime; + + if (timeSinceLastUpdate >= 500) { + const calculatedFPS = Math.round((frameCount / timeSinceLastUpdate) * 1000); + + if (calculatedFPS !== fps) { + for (const listener of fpsListeners) { + listener(calculatedFPS); + } + } + + fps = calculatedFPS; frameCount = 0; lastTime = now; } - requestAnimationFrame(updateFPS); + + requestAnimationFrame(() => updateFPS(onChange)); }; export const getFPS = () => { @@ -67,10 +89,10 @@ export const getFPS = () => { export const isElementVisible = (el: Element) => { const style = window.getComputedStyle(el); return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.contentVisibility !== 'hidden' && - style.opacity !== '0' + style.display !== "none" && + style.visibility !== "hidden" && + style.contentVisibility !== "hidden" && + style.opacity !== "0" ); }; @@ -86,7 +108,7 @@ export const isValueUnstable = (prevValue: unknown, nextValue: unknown) => { export const isElementInViewport = ( el: Element, - rect = el.getBoundingClientRect(), + rect = el.getBoundingClientRect() ) => { const isVisible = rect.bottom > 0 && @@ -122,29 +144,29 @@ export interface Render { fps: number; } -const unstableTypes = ['function', 'object']; +const unstableTypes = ["function", "object"]; const cache = new WeakMap(); export function fastSerialize(value: unknown, depth = 0): string { - if (depth < 0) return '…'; + if (depth < 0) return "…"; switch (typeof value) { - case 'function': + case "function": return value.toString(); - case 'string': + case "string": return value; - case 'number': - case 'boolean': - case 'undefined': + case "number": + case "boolean": + case "undefined": return String(value); - case 'object': + case "object": break; default: return String(value); } - if (value === null) return 'null'; + if (value === null) return "null"; if (cache.has(value)) { const cached = cache.get(value); @@ -154,13 +176,13 @@ export function fastSerialize(value: unknown, depth = 0): string { } if (Array.isArray(value)) { - const str = value.length ? `[${value.length}]` : '[]'; + const str = value.length ? `[${value.length}]` : "[]"; cache.set(value, str); return str; } if (isValidElement(value)) { - const type = getDisplayName(value.type) ?? ''; + const type = getDisplayName(value.type) ?? ""; const propCount = value.props ? Object.keys(value.props).length : 0; const str = `<${type} ${propCount}>`; cache.set(value, str); @@ -169,14 +191,14 @@ export function fastSerialize(value: unknown, depth = 0): string { if (Object.getPrototypeOf(value) === Object.prototype) { const keys = Object.keys(value); - const str = keys.length ? `{${keys.length}}` : '{}'; + const str = keys.length ? `{${keys.length}}` : "{}"; cache.set(value, str); return str; } const ctor = - value && typeof value === 'object' ? value.constructor : undefined; - if (ctor && typeof ctor === 'function' && ctor.name) { + value && typeof value === "object" ? value.constructor : undefined; + if (ctor && typeof ctor === "function" && ctor.name) { const str = `${ctor.name}{…}`; cache.set(value, str); return str; @@ -252,7 +274,7 @@ export const getStateChanges = (fiber: Fiber): StateChange[] => { // when we have class component fiber, memoizedState is the component state const change: StateChange = { type: ChangeReason.ClassState, - name: 'state', + name: "state", value: fiber.memoizedState, prevValue: fiber.alternate?.memoizedState, }; @@ -284,7 +306,7 @@ const getContextId = (contextFiber: ContextFiber) => { function getContextChangesTraversal( this: Array, nextValue: ContextFiber | null | undefined, - prevValue: ContextFiber | null | undefined, + prevValue: ContextFiber | null | undefined ): void { if (!nextValue || !prevValue) return; // const prevMemoizedValue = prevValue.memoizedValue; @@ -294,7 +316,7 @@ function getContextChangesTraversal( type: ChangeReason.Context, name: (nextValue.context as { displayName: string | undefined }).displayName ?? - 'UnnamedContext', + "UnnamedContext", value: nextMemoizedValue, contextType: getContextId(nextValue.context as ContextFiber), @@ -324,7 +346,11 @@ export const getContextChanges = (fiber: Fiber) => { return changes; }; -type OnRenderHandler = (fiber: Fiber, renders: Array) => void; +type OnRenderHandler = ( + fiber: Fiber, + renders: Array, + renderedAt: number +) => void; type OnCommitStartHandler = () => void; type OnCommitFinishHandler = () => void; type OnErrorHandler = (error: unknown) => void; @@ -368,7 +394,7 @@ function isRenderUnnecessaryTraversal( this: IsRenderUnnecessaryState, _propsName: string, prevValue: unknown, - nextValue: unknown, + nextValue: unknown ): void { if ( !isEqual(prevValue, nextValue) && @@ -419,10 +445,9 @@ export const isRenderUnnecessary = (fiber: Fiber) => { const TRACK_UNNECESSARY_RENDERS = false; - export const createInstrumentation = ( instanceKey: string, - config: InstrumentationConfig, + config: InstrumentationConfig ) => { const instrumentation: Instrumentation = { // this will typically be false, but in cases where a user provides showToolbar: true, this will be true @@ -438,26 +463,28 @@ export const createInstrumentation = ( inited = true; instrument({ - name: 'react-scan', + name: "react-scan", onActive: config.onActive, onCommitFiberRoot(_rendererID, root) { instrumentation.fiberRoots.add(root); - if ( - ReactScanInternals.instrumentation?.isPaused.value && - (Store.inspectState.value.kind === 'inspect-off' || - Store.inspectState.value.kind === 'uninitialized') && - !config.forceAlwaysTrackRenders - ) { - return; - } + // for now we always track everything for notifications, it may be worth it to make this configurable + // if ( + // ReactScanInternals.instrumentation?.isPaused.value && + // (Store.inspectState.value.kind === "inspect-off" || + // Store.inspectState.value.kind === "uninitialized") && + // !config.forceAlwaysTrackRenders + // ) { + // return; + // } const allInstances = getAllInstances(); for (const instance of allInstances) { instance.config.onCommitStart(); } + const renderedAt = Date.now(); traverseRenderedFibers( root.current, - (fiber: Fiber, phase: 'mount' | 'update' | 'unmount') => { + (fiber: Fiber, phase: "mount" | "update" | "unmount") => { const type = getType(fiber.type); if (!type) return null; @@ -477,7 +504,6 @@ export const createInstrumentation = ( const changesState = collectStateChanges(fiber).changes; const changesContext = collectContextChanges(fiber).changes; - // Convert props changes changes.push.apply( null, changesProps.map( @@ -486,11 +512,10 @@ export const createInstrumentation = ( type: ChangeReason.Props, name: change.name, value: change.value, - }) as Change, - ), + }) as Change + ) ); - // Convert state changes for (const change of changesState) { if (fiber.tag === ClassComponentTag) { changes.push({ @@ -507,7 +532,6 @@ export const createInstrumentation = ( } } - // Convert context changes changes.push.apply( null, changesContext.map( @@ -517,8 +541,8 @@ export const createInstrumentation = ( name: change.name, value: change.value, contextType: Number(change.contextType), - }) as Change, - ), + }) as Change + ) ); } @@ -544,9 +568,9 @@ export const createInstrumentation = ( for (let i = 0, len = validInstancesIndicies.length; i < len; i++) { const index = validInstancesIndicies[i]; const instance = allInstances[index]; - instance.config.onRender(fiber, [render]); + instance.config.onRender(fiber, [render], renderedAt); } - }, + } ); for (const instance of allInstances) { diff --git a/packages/scan/src/core/monitor/index.ts b/packages/scan/src/core/monitor/index.ts index c1627612..3c2dbc20 100644 --- a/packages/scan/src/core/monitor/index.ts +++ b/packages/scan/src/core/monitor/index.ts @@ -1,23 +1,23 @@ -'use client'; +"use client"; import { type Fiber, getDisplayName, getTimings, isCompositeFiber, -} from 'bippy'; -import { useEffect } from 'react'; +} from "bippy"; +import { type FC, useEffect } from "react"; import { type MonitoringOptions, ReactScanInternals, Store, setOptions, -} from '..'; -import { type Render, createInstrumentation } from '../instrumentation'; -import { updateFiberRenderData } from '../utils'; -import { flush } from './network'; -import { computeRoute } from './params/utils'; -import { initPerformanceMonitoring } from './performance'; -import { getSession } from './utils'; +} from ".."; +import { type Render, createInstrumentation } from "../instrumentation"; +import { updateFiberRenderData } from "../utils"; +import { flush } from "./network"; +import { computeRoute } from "./params/utils"; +import { initPerformanceMonitoring } from "./performance"; +import { getSession } from "./utils"; // max retries before the set of components do not get reported (avoid memory leaks of the set of fibers stored on the component aggregation) const MAX_RETRIES_BEFORE_COMPONENT_GC = 7; @@ -40,10 +40,16 @@ export interface MonitoringProps { export type MonitoringWithoutRouteProps = Omit< MonitoringProps, - 'route' | 'path' + "route" | "path" >; -export const Monitoring = ({ +const DEFAULT_URL = "https://monitoring.react-scan.com/api/v1/ingest"; + +function noopCatch() { + return null; +} + +export const Monitoring: FC = ({ url, apiKey, params, @@ -51,26 +57,27 @@ export const Monitoring = ({ route = null, commit = null, branch = null, -}: MonitoringProps) => { +}) => { if (!apiKey) - throw new Error('Please provide a valid API key for React Scan monitoring'); - url ??= 'https://monitoring.react-scan.com/api/v1/ingest'; + throw new Error("Please provide a valid API key for React Scan monitoring"); + url ??= DEFAULT_URL; Store.monitor.value ??= { pendingRequests: 0, interactions: [], - session: getSession({ commit, branch }).catch(() => null), + session: getSession({ commit, branch }).catch(noopCatch), url, apiKey, route, commit, branch, + interactionListeningForRenders: null, }; // When using Monitoring without framework, we need to compute the route from the path and params if (!route && path && params) { Store.monitor.value.route = computeRoute(path, params); - } else if (typeof window !== 'undefined') { + } else if (typeof window !== "undefined") { Store.monitor.value.route = route ?? path ?? new URL(window.location.toString()).pathname; // this is inaccurate on vanilla react if the path is not provided but used for session route } @@ -90,11 +97,11 @@ export const scanMonitoring = (options: MonitoringOptions) => { let flushInterval: ReturnType; -export const startMonitoring = () => { +export const startMonitoring = (): void => { if (!Store.monitor.value) { - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== "production") { throw new Error( - 'Invariant: startMonitoring can never be called when monitoring is not initialized', + "Invariant: startMonitoring can never be called when monitoring is not initialized" ); } } @@ -114,7 +121,7 @@ export const startMonitoring = () => { globalThis.__REACT_SCAN__ = { ReactScanInternals, }; - const instrumentation = createInstrumentation('monitoring', { + const instrumentation = createInstrumentation("monitoring", { onCommitStart() { // ReactScanInternals.options.value.onCommitStart?.(); }, @@ -135,6 +142,9 @@ export const startMonitoring = () => { onCommitFinish() { // ReactScanInternals.options.value.onCommitFinish?.(); }, + // onPostCommitFiberRoot() { + // // ... + // }, trackChanges: false, forceAlwaysTrackRenders: true, }); @@ -144,8 +154,8 @@ export const startMonitoring = () => { const aggregateComponentRenderToInteraction = ( fiber: Fiber, - renders: Array, -) => { + renders: Array +): void => { const monitor = Store.monitor.value; if (!monitor || !monitor.interactions || monitor.interactions.length === 0) return; diff --git a/packages/scan/src/core/monitor/performance.ts b/packages/scan/src/core/monitor/performance.ts index 27803d35..91465c00 100644 --- a/packages/scan/src/core/monitor/performance.ts +++ b/packages/scan/src/core/monitor/performance.ts @@ -1,10 +1,10 @@ -import { type Fiber, getDisplayName } from 'bippy'; -import { getCompositeComponentFromElement } from '~web/components/inspector/utils'; -import { Store } from '..'; +import { type Fiber, getDisplayName } from "bippy"; +import { getCompositeComponentFromElement } from "~web/views/inspector/utils"; +import { Store } from ".."; import type { PerformanceInteraction, PerformanceInteractionEntry, -} from './types'; +} from "./types"; interface PathFilters { skipProviders: boolean; @@ -43,7 +43,7 @@ const FILTER_PATTERNS = { const shouldIncludeInPath = ( name: string, - filters: PathFilters = DEFAULT_FILTERS, + filters: PathFilters = DEFAULT_FILTERS ): boolean => { const patternsToCheck: Array = []; if (filters.skipProviders) patternsToCheck.push(...FILTER_PATTERNS.providers); @@ -65,10 +65,6 @@ const minifiedPatterns = [ ]; const isMinified = (name: string): boolean => { - if (!name || typeof name !== 'string') { - return true; - } - for (let i = 0; i < minifiedPatterns.length; i++) { if (minifiedPatterns[i].test(name)) return true; } @@ -90,7 +86,7 @@ const isMinified = (name: string): boolean => { export const getInteractionPath = ( initialFiber: Fiber | null, - filters: PathFilters = DEFAULT_FILTERS, + filters: PathFilters = DEFAULT_FILTERS ): Array => { if (!initialFiber) return []; @@ -123,11 +119,11 @@ interface FiberType { const getCleanComponentName = (component: FiberType): string => { const name = getDisplayName(component); - if (!name) return ''; + if (!name) return ""; return name.replace( /^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/, - '$', + "$" ); }; @@ -149,8 +145,7 @@ const getFirstNamedAncestorCompositeFiber = (element: Element) => { while (!parentCompositeFiber && curr.parentElement) { curr = curr.parentElement; - const { parentCompositeFiber: fiber } = - getCompositeComponentFromElement(curr); + const fiber = getCompositeComponentFromElement(curr).parentCompositeFiber; if (!fiber) { continue; @@ -162,22 +157,18 @@ const getFirstNamedAncestorCompositeFiber = (element: Element) => { return parentCompositeFiber; }; -let unsubscribeTrackVisibilityChange: (() => void) | undefined; // fixme: compress me if this stays here for bad interaction time checks -let lastVisibilityHiddenAt: number | 'never-hidden' = 'never-hidden'; +let lastVisibilityHiddenAt: number | "never-hidden" = "never-hidden"; -const trackVisibilityChange = () => { - unsubscribeTrackVisibilityChange?.(); - const onVisibilityChange = () => { - if (document.hidden) { - lastVisibilityHiddenAt = Date.now(); - } - }; - document.addEventListener('visibilitychange', onVisibilityChange); +const onVisibilityChange = () => { + if (document.hidden) { + lastVisibilityHiddenAt = Date.now(); + } +}; - unsubscribeTrackVisibilityChange = () => { - document.removeEventListener('visibilitychange', onVisibilityChange); - }; +const trackVisibilityChange = () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + document.addEventListener("visibilitychange", onVisibilityChange); }; // todo: update monitoring api to expose filters for component names @@ -186,10 +177,10 @@ export function initPerformanceMonitoring(options?: Partial) { const monitor = Store.monitor.value; if (!monitor) return; - document.addEventListener('mouseover', handleMouseover); + document.addEventListener("mouseover", handleMouseover); const disconnectPerformanceListener = setupPerformanceListener((entry) => { const target = - entry.target ?? (entry.type === 'pointer' ? currentMouseOver : null); + entry.target ?? (entry.type === "pointer" ? currentMouseOver : null); if (!target) { // most likely an invariant that we should log if its violated return; @@ -222,31 +213,34 @@ export function initPerformanceMonitoring(options?: Partial) { return () => { disconnectPerformanceListener(); - document.removeEventListener('mouseover', handleMouseover); + document.removeEventListener("mouseover", handleMouseover); }; } +const POINTER_EVENTS = new Set(["pointerdown", "pointerup", "click"]); +const KEYBOARD_EVENTS = new Set(["keydown", "keyup"]); + const getInteractionType = ( - eventName: string, -): 'pointer' | 'keyboard' | null => { - if (['pointerdown', 'pointerup', 'click'].includes(eventName)) { - return 'pointer'; + eventName: string +): "pointer" | "keyboard" | null => { + if (POINTER_EVENTS.has(eventName)) { + return "pointer"; } - if (['keydown', 'keyup'].includes(eventName)) { - return 'keyboard'; + if (KEYBOARD_EVENTS.has(eventName)) { + return "keyboard"; } return null; }; const setupPerformanceListener = ( - onEntry: (interaction: PerformanceInteraction) => void, + onEntry: (interaction: PerformanceInteraction) => void ) => { trackVisibilityChange(); const longestInteractionMap = new Map(); const interactionTargetMap = new Map(); const processInteractionEntry = (entry: PerformanceInteractionEntry) => { - if (!(entry.interactionId || entry.entryType === 'first-input')) return; + if (!(entry.interactionId || entry.entryType === "first-input")) return; if ( entry.interactionId && @@ -288,8 +282,8 @@ const setupPerformanceListener = ( entry.duration - (entry.processingEnd - entry.startTime), timestamp: Date.now(), timeSinceTabInactive: - lastVisibilityHiddenAt === 'never-hidden' - ? 'never-hidden' + lastVisibilityHiddenAt === "never-hidden" + ? "never-hidden" : Date.now() - lastVisibilityHiddenAt, visibilityState: document.visibilityState, timeOrigin: performance.timeOrigin, @@ -311,17 +305,17 @@ const setupPerformanceListener = ( try { po.observe({ - type: 'event', + type: "event", buffered: true, durationThreshold: 16, } as PerformanceObserverInit); po.observe({ - type: 'first-input', + type: "first-input", buffered: true, }); } catch { /* Should collect error logs*/ } - return () => po.disconnect(); + return po.disconnect.bind(po); }; diff --git a/packages/scan/src/core/monitor/utils.ts b/packages/scan/src/core/monitor/utils.ts index 4cc1e091..d872cee0 100644 --- a/packages/scan/src/core/monitor/utils.ts +++ b/packages/scan/src/core/monitor/utils.ts @@ -12,17 +12,18 @@ interface ExtendedNavigator extends Navigator { deviceMemory?: number; } +const MOBILE_PATTERN = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + +const TABLET_PATTERN = /iPad|Tablet/i; + const getDeviceType = () => { const userAgent = navigator.userAgent; - if ( - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - userAgent, - ) - ) { + if (MOBILE_PATTERN.test(userAgent)) { return Device.MOBILE; } - if (/iPad|Tablet/i.test(userAgent)) { + if (TABLET_PATTERN.test(userAgent)) { return Device.TABLET; } return Device.DESKTOP; @@ -32,9 +33,7 @@ const getDeviceType = () => { * Measure layout time */ export const doubleRAF = (callback: (...args: unknown[]) => void) => { - return requestAnimationFrame(() => { - requestAnimationFrame(callback); - }); + return requestAnimationFrame(requestAnimationFrame.bind(window, callback)); }; export const generateId = () => { diff --git a/packages/scan/src/core/notifications/event-tracking.ts b/packages/scan/src/core/notifications/event-tracking.ts new file mode 100644 index 00000000..21180403 --- /dev/null +++ b/packages/scan/src/core/notifications/event-tracking.ts @@ -0,0 +1,418 @@ +import { useSyncExternalStore } from "preact/compat"; +import { createStore } from "../create-store"; +import { createHighlightCanvas } from "../heatmap-overlay"; +import { MAX_INTERACTION_BATCH, interactionStore } from "./interaction-store"; +import { + FiberRenders, + PerformanceEntryChannelEvent, + TimeoutStage, + listenForPerformanceEntryInteractions, + listenForRenders, + setupDetailedPointerTimingListener, + setupPerformancePublisher, +} from "./performance"; +import { + MAX_CHANNEL_SIZE, + performanceEntryChannels, +} from "./performance-store"; +import { BoundedArray } from "./performance-utils"; +import { generateId } from "~core/monitor/utils"; + +let profileListeners: Array<(interaction: FinalInteraction) => void> = []; + +type FinalInteraction = { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; +}; + +export const listenForProfile = ( + listener: (interaction: FinalInteraction) => void +) => { + profileListeners.push(listener); + + return () => { + profileListeners = profileListeners.filter( + (existingListener) => existingListener !== listener + ); + }; +}; + +export let interactionStatus: + | { kind: "started"; startedAt: number } + | { kind: "completed"; startedAt: number; endedAt: number } + | { kind: "no-interaction" } = { + kind: "no-interaction", +}; + +type InteractionStoreState = + | { kind: "started"; startedAt: number } + | { kind: "completed"; startedAt: number; endedAt: number } + | { kind: "no-interaction" }; + +type NewInteractionStoreState = { + /** + * problem definition: we need to store bounds but how do we handle uninitialized bounds + * + * i guess what we said before, we just have one active bounds and that's all that matters chat + */ + + startAt: number; + endAt: number; +}; + +export const interactionStatusStore: { + state: NewInteractionStoreState | null; + listeners: Array<(state: NewInteractionStoreState) => void>; + addListener: (cb: (state: NewInteractionStoreState) => void) => () => void; +} = { + state: null, + addListener: (cb) => { + interactionStatusStore.listeners.push(cb); + return () => { + interactionStatusStore.listeners = + interactionStatusStore.listeners.filter((l) => l !== cb); + }; + }, + listeners: [], +}; + +let accumulatedFiberRendersOverTask: null | FiberRenders = null; +type InteractionEvent = { + kind: "interaction"; + data: { + startAt: number; + endAt: number; + meta: { + detailedTiming: TimeoutStage; + latency: number; + kind: PerformanceEntryChannelEvent["kind"]; + }; + }; +}; + +type LongRenderPipeline = { + kind: "long-render"; + data: { + startAt: number; + endAt: number; + meta: { + latency: number; + fiberRenders: FiberRenders; + fps: number; + }; + }; +}; + +export type SlowdownEvent = (InteractionEvent | LongRenderPipeline) & { + id: string; +}; + +type ToolbarEventStoreState = { + state: { + events: Array; + }; + actions: { + addEvent: (event: SlowdownEvent) => void; + addListener: (listener: (event: SlowdownEvent) => void) => () => void; + clear: () => void; + }; +}; + +type DebugEvent = { + kind: string; + at: number; + meta?: unknown; +}; +export const debugEventStore = createStore<{ + state: { + events: Array; + }; + actions: { + addEvent: (event: any) => void; + clear: () => void; + }; +}>()((set) => ({ + state: { + events: [], + }, + actions: { + addEvent: (event: DebugEvent) => { + set((store) => ({ + state: { + events: [...store.state.events, event], + }, + })); + }, + clear: () => { + set({ + state: { + events: [], + }, + }); + }, + }, +})); + +export const toolbarEventStore = createStore()(( + set, + get +) => { + const listeners = new Set<(event: SlowdownEvent) => void>(); + + return { + state: { + events: [], + }, + + actions: { + addEvent: (event: SlowdownEvent) => { + listeners.forEach((listener) => listener(event)); + + const events = [...get().state.events, event]; + const applyOverlapCheckToLongRenderEvent = ( + longRenderEvent: LongRenderPipeline & { id: string }, + onOverlap: (overlapsWith: InteractionEvent & { id: string }) => void + ) => { + const overlapsWith = events.find((event) => { + if (event.kind === "long-render") { + return; + } + + if (event.id === longRenderEvent.id) { + return; + } + + /** + * |---x-----------x------ (interaction) + * |x-----------x (long-render) + */ + + if ( + longRenderEvent.data.startAt <= event.data.startAt && + longRenderEvent.data.endAt <= event.data.endAt && + longRenderEvent.data.endAt >= event.data.startAt + ) { + return true; + } + + /** + * |x-----------x---- (interaction) + * |--x------------x (long-render) + * + + */ + + if ( + event.data.startAt <= longRenderEvent.data.startAt && + event.data.endAt >= longRenderEvent.data.startAt + ) { + return true; + } + + /** + * + * |--x-------------x (interaction) + * |x------------------x (long-render) + * + */ + + if ( + longRenderEvent.data.startAt <= event.data.startAt && + longRenderEvent.data.endAt >= event.data.endAt + ) { + return true; + } + }) as undefined | (InteractionEvent & { id: string }); // invariant: because we early check the typechecker does not know it must be the case that when it finds something, it will be an interaction it overlaps with + + if (overlapsWith) { + onOverlap(overlapsWith); + } + }; + + const toRemove = new Set(); + + events.forEach((event) => { + if (event.kind === "interaction") return; + applyOverlapCheckToLongRenderEvent(event, () => { + toRemove.add(event.id); + }); + }); + + const withRemovedEvents = events.filter( + (event) => !toRemove.has(event.id) + ); + + set(() => ({ + state: { + events: withRemovedEvents, + }, + })); + }, + + addListener: (listener: (event: SlowdownEvent) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + clear: () => { + set({ + state: { + events: [], + }, + }); + }, + }, + }; +}); + +export const useToolbarEventLog = () => { + return useSyncExternalStore( + toolbarEventStore.subscribe, + toolbarEventStore.getState + ); +}; + +let isTaskDirty = false; + +// stops long tasks b/c backgrounded from being reported +export const startDirtyTaskTracking = () => { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + return; + } + isTaskDirty = true; + }); +}; + +let framesDrawnInTheLastSecond: Array = []; + +export function startLongPipelineTracking() { + let rafHandle: number; + let timeoutHandle: NodeJS.Timeout; + + function measure() { + let unSub: (() => void) | null = null; + accumulatedFiberRendersOverTask = null; + accumulatedFiberRendersOverTask = {}; + unSub = listenForRenders(accumulatedFiberRendersOverTask); + const startOrigin = performance.timeOrigin; + const startTime = performance.now(); + rafHandle = requestAnimationFrame(() => { + // very low overhead, on the order of dozens of microseconds to run + timeoutHandle = setTimeout(() => { + const endNow = performance.now(); + const duration = endNow - startTime; + const endOrigin = performance.timeOrigin; + framesDrawnInTheLastSecond.push(endNow); + + const framesInTheLastSecond = framesDrawnInTheLastSecond.filter( + (frameAt) => endNow - frameAt <= 1000 + ); + + const fps = framesInTheLastSecond.length; + framesDrawnInTheLastSecond = framesInTheLastSecond; + + if (duration > 200 && !isTaskDirty) { + const endAt = endOrigin + endNow; + const startAt = startTime + startOrigin; + + toolbarEventStore.getState().actions.addEvent({ + kind: "long-render", + id: generateId(), + data: { + endAt: endAt, + startAt: startAt, + meta: { + fiberRenders: accumulatedFiberRendersOverTask!, + latency: duration, + fps, + }, + }, + }); + } + + isTaskDirty = false; + + unSub?.(); + measure(); + }, 0); + }); + } + + measure(); + + return () => { + cancelAnimationFrame(rafHandle); + clearTimeout(timeoutHandle); + }; +} +export const startTimingTracking = () => { + const unSubPerformance = setupPerformancePublisher(); + startDirtyTaskTracking(); + startLongPipelineTracking(); + createHighlightCanvas(document.body); + + const onComplete = async ( + _: string, + finalInteraction: FinalInteraction, + event: PerformanceEntryChannelEvent + ) => { + toolbarEventStore.getState().actions.addEvent({ + kind: "interaction", + id: crypto.randomUUID(), + data: { + startAt: finalInteraction.detailedTiming.blockingTimeStart, + endAt: performance.now() + performance.timeOrigin, + meta: { ...finalInteraction, kind: event.kind }, // TODO, will need interaction specific metadata here + }, + }); + + const existingCompletedInteractions = + performanceEntryChannels.getChannelState("recording"); + + finalInteraction.detailedTiming.stopListeningForRenders(); + + if (existingCompletedInteractions.length) { + // then performance entry and our detailed timing handlers are out of sync, we disregard that entry + // it may be possible the performance entry returned before detailed timing. If that's the case we should update + // assumptions and deal with mapping the entry back to the detailed timing here + performanceEntryChannels.updateChannelState( + "recording", + () => new BoundedArray(MAX_CHANNEL_SIZE) + ); + } + }; + const unSubDetailedPointerTiming = setupDetailedPointerTimingListener( + "pointer", + { + onComplete, + } + ); + const unSubDetailedKeyboardTiming = setupDetailedPointerTimingListener( + "keyboard", + { + onComplete, + } + ); + + const unSubInteractions = listenForPerformanceEntryInteractions( + (completedInteraction) => { + interactionStore.setState( + BoundedArray.fromArray( + interactionStore.getCurrentState().concat(completedInteraction), + MAX_INTERACTION_BATCH + ) + ); + } + ); + + return () => { + unSubPerformance(); + unSubDetailedPointerTiming(); + unSubInteractions(); + unSubDetailedKeyboardTiming(); + }; +}; diff --git a/packages/scan/src/core/notifications/interaction-store.ts b/packages/scan/src/core/notifications/interaction-store.ts new file mode 100644 index 00000000..95e940e5 --- /dev/null +++ b/packages/scan/src/core/notifications/interaction-store.ts @@ -0,0 +1,36 @@ +import { BoundedArray } from "~core/notifications/performance-utils"; +import { CompletedInteraction } from "./performance"; + +type Subscriber = (data: T) => void; + +export class Store { + private subscribers: Set> = new Set(); + private currentValue: T; + + constructor(initialValue: T) { + this.currentValue = initialValue; + } + + subscribe(subscriber: Subscriber): () => void { + this.subscribers.add(subscriber); + + subscriber(this.currentValue); + + return () => { + this.subscribers.delete(subscriber); + }; + } + + setState(data: T) { + this.currentValue = data; + this.subscribers.forEach((subscriber) => subscriber(data)); + } + + getCurrentState(): T { + return this.currentValue; + } +} +export const MAX_INTERACTION_BATCH = 150; +export const interactionStore = new Store>( + new BoundedArray(MAX_INTERACTION_BATCH) +); diff --git a/packages/scan/src/core/notifications/performance-store.ts b/packages/scan/src/core/notifications/performance-store.ts new file mode 100644 index 00000000..36af15b8 --- /dev/null +++ b/packages/scan/src/core/notifications/performance-store.ts @@ -0,0 +1,120 @@ +import { BoundedArray } from "./performance-utils"; +import { PerformanceEntryChannelEvent } from "./performance"; + +type UnSubscribe = () => void; +type Callback = (item: T) => void; +type Updater = (state: BoundedArray) => BoundedArray; +type ChanelName = string; + +type PerformanceEntryChannelsType = { + subscribe: (to: ChanelName, cb: Callback) => UnSubscribe; + publish: ( + item: T, + to: ChanelName, + dropFirst: boolean, + createIfNoChannel: boolean + ) => void; + channels: Record< + ChanelName, + { callbacks: BoundedArray>; state: BoundedArray } + >; + getAvailableChannels: () => BoundedArray; + updateChannelState: ( + channel: ChanelName, + updater: Updater, + createIfNoChannel: boolean + ) => void; +}; + +export const MAX_CHANNEL_SIZE = 50; +// a set of entities communicate to each other through channels +// the state in the channel is persisted until the receiving end consumes it +// multiple subscribes to the same channel will likely lead to unintended behavior if the subscribers are separate entities +class PerformanceEntryChannels implements PerformanceEntryChannelsType { + channels: PerformanceEntryChannelsType["channels"] = {}; + publish(item: T, to: ChanelName, createIfNoChannel = true) { + const existingChannel = this.channels[to]; + if (!existingChannel) { + if (!createIfNoChannel) { + return; + } + this.channels[to] = { + callbacks: new BoundedArray>(MAX_CHANNEL_SIZE), + state: new BoundedArray(MAX_CHANNEL_SIZE), + }; + this.channels[to].state.push(item); + return; + } + + existingChannel.state.push(item); + existingChannel.callbacks.forEach((cb) => cb(item)); + } + + getAvailableChannels() { + return BoundedArray.fromArray(Object.keys(this.channels), MAX_CHANNEL_SIZE); + } + subscribe(to: ChanelName, cb: Callback, dropFirst: boolean = false) { + const defer = () => { + if (!dropFirst) { + this.channels[to].state.forEach((item) => { + cb(item); + }); + } + return () => { + const filtered = this.channels[to].callbacks.filter( + (subscribed) => subscribed !== cb + ); + this.channels[to].callbacks = BoundedArray.fromArray( + filtered, + MAX_CHANNEL_SIZE + ); + }; + }; + const existing = this.channels[to]; + if (!existing) { + this.channels[to] = { + callbacks: new BoundedArray>(MAX_CHANNEL_SIZE), + state: new BoundedArray(MAX_CHANNEL_SIZE), + }; + this.channels[to].callbacks.push(cb); + return defer(); + } + + existing.callbacks.push(cb); + return defer(); + } + updateChannelState( + channel: ChanelName, + updater: Updater, + createIfNoChannel = true + ) { + const existingChannel = this.channels[channel]; + if (!existingChannel) { + if (!createIfNoChannel) { + return; + } + + const state = new BoundedArray(MAX_CHANNEL_SIZE); + const newChannel = { + callbacks: new BoundedArray>(MAX_CHANNEL_SIZE), + state, + }; + + this.channels[channel] = newChannel; + newChannel.state = updater(state); + return; + } + + existingChannel.state = updater(existingChannel.state); + } + + getChannelState(channel: ChanelName) { + return ( + this.channels[channel].state ?? new BoundedArray(MAX_CHANNEL_SIZE) + ); + } +} +// todo: discriminated union the events when we start using multiple channels +// we used to use multiple channels, but now we only use 1. This is still a useful abstraction incase we ever need more channels again +export const performanceEntryChannels = + new PerformanceEntryChannels(); diff --git a/packages/scan/src/core/notifications/performance-utils.ts b/packages/scan/src/core/notifications/performance-utils.ts new file mode 100644 index 00000000..1764afc7 --- /dev/null +++ b/packages/scan/src/core/notifications/performance-utils.ts @@ -0,0 +1,114 @@ +import { Fiber } from "bippy"; +export const getChildrenFromFiberLL = (fiber: Fiber) => { + const children: Array = []; + + let curr: typeof fiber.child = fiber.child; + + while (curr) { + children.push(curr); + + curr = curr.sibling; + } + + return children; +}; + +type Node = Map< + Fiber, + { + children: Array; + parent: Fiber | null; + isRoot: boolean; + isSVG: boolean; + } +>; + +export const createChildrenAdjacencyList = (root: Fiber, limit: number) => { + const tree: Node = new Map([]); + + const queue: Array<[node: Fiber, parent: Fiber | null]> = []; + const visited = new Set(); + + queue.push([root, root.return]); + let traversed = 1; + + while (queue.length) { + if (traversed >= limit) { + return tree; + } + const [node, parent] = queue.pop()!; + const children = getChildrenFromFiberLL(node); + + tree.set(node, { + children: [], + parent, + isRoot: node === root, + isSVG: node.type === "svg", + }); + + for (const child of children) { + traversed += 1; + // this isn't needed since the fiber tree is a TREE, not a graph, but it makes me feel safer + if (visited.has(child)) { + continue; + } + visited.add(child); + tree.get(node)?.children.push(child); + queue.push([child, node]); + } + } + return tree; +}; + +const isProduction: boolean = process.env.NODE_ENV === "production"; +const prefix: string = "Invariant failed"; + +// FIX ME THIS IS PRODUCTION INVARIANT LOL +export function devInvariant( + condition: any, + message?: string | (() => string) +): asserts condition { + if (condition) { + return; + } + + if (isProduction) { + throw new Error(prefix); + } + + const provided: string | undefined = + typeof message === "function" ? message() : message; + + const value: string = provided ? `${prefix}: ${provided}` : prefix; + throw new Error(value); +} + +const THROW_INVARIANTS = false; + +export const invariantError = (message: string | undefined) => { + if (THROW_INVARIANTS) { + throw new Error(message); + } +}; + +export const iife = (fn: () => T): T => fn(); + +export class BoundedArray extends Array { + constructor(private capacity: number = 25) { + super(); + } + + push(...items: T[]): number { + const result = super.push(...items); + while (this.length > this.capacity) { + this.shift(); + } + return result; + } + // do not couple capacity with a default param, it must be explicit + static fromArray(array: Array, capacity: number) { + const arr = new BoundedArray(capacity); + arr.push(...array); + return arr; + } +} diff --git a/packages/scan/src/core/notifications/performance.ts b/packages/scan/src/core/notifications/performance.ts new file mode 100644 index 00000000..241d755a --- /dev/null +++ b/packages/scan/src/core/notifications/performance.ts @@ -0,0 +1,1110 @@ +import { + Fiber, + getDisplayName, + getTimings, + isHostFiber, + traverseFiber, +} from "bippy"; +import { Store } from "../.."; + +import { + BoundedArray, + createChildrenAdjacencyList, + invariantError, +} from "~core/notifications/performance-utils"; +import { + SectionData, + collectInspectorDataWithoutCounts, +} from "~web/views/inspector/timeline/utils"; +import { + getCompositeComponentFromElement, + getFiberFromElement, + getParentCompositeFiber, +} from "~web/views/inspector/utils"; +import { performanceEntryChannels } from "./performance-store"; +import type { + PerformanceInteraction, + PerformanceInteractionEntry, +} from "./types"; + +interface PathFilters { + skipProviders: boolean; + skipHocs: boolean; + skipContainers: boolean; + skipMinified: boolean; + skipUtilities: boolean; + skipBoundaries: boolean; +} + +const DEFAULT_FILTERS: PathFilters = { + skipProviders: true, + skipHocs: true, + skipContainers: true, + skipMinified: true, + skipUtilities: true, + skipBoundaries: true, +}; + +const FILTER_PATTERNS = { + providers: [/Provider$/, /^Provider$/, /^Context$/], + hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/], + containers: [/^(?:App)?Container$/, /^Root$/, /^ReactDev/], + utilities: [ + /^Fragment$/, + /^Suspense$/, + /^ErrorBoundary$/, + /^Portal$/, + /^Consumer$/, + /^Layout$/, + /^Router/, + /^Hydration/, + ], + boundaries: [/^Boundary$/, /Boundary$/, /^Provider$/, /Provider$/], +}; + +const shouldIncludeInPath = ( + name: string, + filters: PathFilters = DEFAULT_FILTERS +): boolean => { + const patternsToCheck: Array = []; + if (filters.skipProviders) patternsToCheck.push(...FILTER_PATTERNS.providers); + if (filters.skipHocs) patternsToCheck.push(...FILTER_PATTERNS.hocs); + if (filters.skipContainers) + patternsToCheck.push(...FILTER_PATTERNS.containers); + if (filters.skipUtilities) patternsToCheck.push(...FILTER_PATTERNS.utilities); + if (filters.skipBoundaries) + patternsToCheck.push(...FILTER_PATTERNS.boundaries); + return !patternsToCheck.some((pattern) => pattern.test(name)); +}; + +const minifiedPatterns = [ + /^[a-z]$/, // Single lowercase letter + /^[a-z][0-9]$/, // Lowercase letter followed by number + /^_+$/, // Just underscores + /^[A-Za-z][_$]$/, // Letter followed by underscore or dollar + /^[a-z]{1,2}$/, // 1-2 lowercase letters +]; + +const isMinified = (name: string): boolean => { + if (!name || typeof name !== "string") { + return true; + } + + for (let i = 0; i < minifiedPatterns.length; i++) { + if (minifiedPatterns[i].test(name)) return true; + } + + const hasNoVowels = !/[aeiou]/i.test(name); + const hasMostlyNumbers = (name.match(/\d/g)?.length ?? 0) > name.length / 2; + const isSingleWordLowerCase = /^[a-z]+$/.test(name); + const hasRandomLookingChars = /[$_]{2,}/.test(name); + + // If more than 2 of the following are true, we consider the name minified + return ( + Number(hasNoVowels) + + Number(hasMostlyNumbers) + + Number(isSingleWordLowerCase) + + Number(hasRandomLookingChars) >= + 2 + ); +}; + +export const getInteractionPath = ( + fiber: Fiber | null, + filters: PathFilters = DEFAULT_FILTERS +): Array => { + if (!fiber) { + return []; + } + + const stack = new Array(); + let currentFiber = fiber; + while (currentFiber.return) { + const name = getCleanComponentName(currentFiber.type); + + if (name && !isMinified(name) && shouldIncludeInPath(name, filters)) { + stack.push(name); + } + currentFiber = currentFiber.return; + } + + const fullPath = new Array(stack.length); + for (let i = 0; i < stack.length; i++) { + fullPath[i] = stack[stack.length - i - 1]; + } + + return fullPath; +}; + +let currentMouseOver: Element; + +const getCleanComponentName = ( + component: any /** fiber.type is any */ +): string => { + const name = getDisplayName(component); + if (!name) return ""; + + return name.replace( + /^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/, + "$" + ); +}; + +// For future use, normalization of paths happens on server side now using path property of interaction +const _normalizePath = (path: Array): string => { + const cleaned = path.filter(Boolean); + const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); + return deduped.join("."); +}; + +const handleMouseover = (event: Event) => { + if (!(event.target instanceof Element)) return; + currentMouseOver = event.target; +}; + +const getFirstNamedAncestorCompositeFiber = (element: Element) => { + let curr: Element | null = element; + let parentCompositeFiber: Fiber | null = null; + while (!parentCompositeFiber && curr.parentElement) { + curr = curr.parentElement; + + const { parentCompositeFiber: fiber } = + getCompositeComponentFromElement(curr); + + if (!fiber) { + continue; + } + if (getDisplayName(fiber?.type)) { + parentCompositeFiber = fiber; + } + } + return parentCompositeFiber; +}; + +const getFirstNameFromAncestor = ( + fiber: Fiber, + accept: (name: string) => boolean = () => true +) => { + let curr: Fiber | null = fiber; + + while (curr) { + const currName = getDisplayName(curr.type); + if (currName && accept(currName)) { + return currName; + } + + curr = curr.return; + } + return null; +}; + +let unsubscribeTrackVisibilityChange: (() => void) | undefined; +// fixme: compress me if this stays here for bad interaction time checks +let lastVisibilityHiddenAt: number | "never-hidden" = "never-hidden"; + +const trackVisibilityChange = () => { + unsubscribeTrackVisibilityChange?.(); + const onVisibilityChange = () => { + if (document.hidden) { + lastVisibilityHiddenAt = Date.now(); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + + unsubscribeTrackVisibilityChange = () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }; +}; +export type FiberRenders = Record< + string, + { + renderCount: number; + parents: Set; + selfTime: number; + totalTime: number; + nodeInfo: Array<{ + selfTime: number; + element: Element; + name: string; + }>; + changes: ReturnType; + } +>; + +/** + * we need to fix: + * - if there's a tab switch during a task being tracked, then u disregard that task (i hope this doesn't make tab switches hard to debug that cause slowdowns, ug i suppose it probably would, right? Depends how the browser queues it but i suppose u can think of a scenario. It would be most optimal to subtract the timing but not sure how reliable that would be) + * - we need to see why the tracking is just off + * - we need to correctly implement the precise activation this time + */ + +type InteractionStartStage = { + kind: "interaction-start"; + interactionType: "pointer" | "keyboard"; + interactionUUID: string; + interactionStartDetail: number; + blockingTimeStart: number; + componentPath: Array; + componentName: string; + childrenTree: Record< + string, + { children: Array; firstNamedAncestor: string; isRoot: boolean } + >; + fiberRenders: FiberRenders; + stopListeningForRenders: () => void; +}; + +type JSEndStage = Omit & { + kind: "js-end-stage"; + jsEndDetail: number; +}; + +type RAFStage = Omit & { + kind: "raf-stage"; + rafStart: number; +}; + +export type TimeoutStage = Omit & { + kind: "timeout-stage"; + commitEnd: number; + blockingTimeEnd: number; +}; + +export type PerformanceEntryChannelEvent = + | { + kind: "entry-received"; + entry: PerformanceInteraction; + } + | { + kind: "auto-complete-race"; + interactionUUID: string; + detailedTiming: TimeoutStage; + }; + +export type CompletedInteraction = { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; + flushNeeded: boolean; +}; + +type UnInitializedStage = { + kind: "uninitialized-stage"; + interactionUUID: string; + interactionType: "pointer" | "keyboard"; +}; + +type CurrentInteraction = { + kind: "pointer" | "keyboard"; + interactionUUID: string; + pointerUpStart: number; + // needed for when inputs that can be clicked and trigger on change (like checkboxes) + clickChangeStart: number | null; + clickHandlerMicroTaskEnd: number | null; + rafStart: number | null; + commmitEnd: number | null; + timeorigin: number; + + // for now i don't trust performance now timing for UTC time... + blockingTimeStart: number; + blockingTimeEnd: number | null; + fiberRenders: Map< + string, + { + renderCount: number; + parents: Set; + selfTime: number; + } + >; + componentPath: Array; + componentName: string; + childrenTree: Record< + string, + { children: Array; firstNamedAncestor: string; isRoot: boolean } + >; +}; + +export let currentInteractions: Array = []; +export const fastHash = (str: string): string => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(36); +}; +const getInteractionType = ( + eventName: string +): "pointer" | "keyboard" | null => { + // todo: track pointer down, but tends to not house expensive logic so not very high priority + if (["pointerup", "click"].includes(eventName)) { + return "pointer"; + } + if (eventName.includes("key")) { + } + if (["keydown", "keyup"].includes(eventName)) { + return "keyboard"; + } + return null; +}; +export const getInteractionId = (interaction: any) => { + return `${interaction.performanceEntry.type}::${normalizePath(interaction.componentPath)}::${interaction.url}`; +}; +export function normalizePath(path: string[]): string { + const cleaned = path.filter(Boolean); + + const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); + + return deduped.join("."); +} +let onEntryAnimationId: number | null = null; +const setupPerformanceListener = ( + onEntry: (interaction: PerformanceInteraction) => void +) => { + trackVisibilityChange(); + const interactionMap = new Map(); + const interactionTargetMap = new Map(); + + const processInteractionEntry = (entry: PerformanceInteractionEntry) => { + if (!entry.interactionId) return; + + if ( + entry.interactionId && + entry.target && + !interactionTargetMap.has(entry.interactionId) + ) { + interactionTargetMap.set(entry.interactionId, entry.target); + } + + const existingInteraction = interactionMap.get(entry.interactionId); + + if (existingInteraction) { + if (entry.duration > existingInteraction.latency) { + existingInteraction.entries = [entry]; + existingInteraction.latency = entry.duration; + } else if ( + entry.duration === existingInteraction.latency && + entry.startTime === existingInteraction.entries[0].startTime + ) { + existingInteraction.entries.push(entry); + } + } else { + const interactionType = getInteractionType(entry.name); + if (!interactionType) { + return; + } + + const interaction: PerformanceInteraction = { + id: entry.interactionId, + latency: entry.duration, + entries: [entry], + target: entry.target, + type: interactionType, + startTime: entry.startTime, + endTime: Date.now(), + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + duration: entry.duration, + inputDelay: entry.processingStart - entry.startTime, + processingDuration: entry.processingEnd - entry.processingStart, + presentationDelay: + entry.duration - (entry.processingEnd - entry.startTime), + // componentPath: + timestamp: Date.now(), + timeSinceTabInactive: + lastVisibilityHiddenAt === "never-hidden" + ? "never-hidden" + : Date.now() - lastVisibilityHiddenAt, + visibilityState: document.visibilityState, + timeOrigin: performance.timeOrigin, + referrer: document.referrer, + }; + // + interactionMap.set(interaction.id, interaction); + + /** + * This seems odd, but it gives us determinism that we will receive an entry AFTER our detailed timing collection + * runs because browser semantics (raf(() => setTimeout) will always run before a doubleRaf) + * + * this also handles the case where multiple entries are dispatched for semantically the same interaction, + * they will get merged into a single interaction, where the largest latency is recorded, which is what + * we are interested in this application + */ + + if (!onEntryAnimationId) { + onEntryAnimationId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + onEntry(interactionMap.get(interaction.id)!); + onEntryAnimationId = null; + }); + }); + } + } + }; + + const po = new PerformanceObserver((list) => { + const entries = list.getEntries(); + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + processInteractionEntry(entry as PerformanceInteractionEntry); + } + }); + + try { + po.observe({ + type: "event", + buffered: true, + durationThreshold: 16, + } as PerformanceObserverInit); + po.observe({ + type: "first-input", + buffered: true, + }); + } catch { + /* Should collect error logs*/ + } + + return () => po.disconnect(); +}; + +export const setupPerformancePublisher = () => { + return setupPerformanceListener((entry) => { + performanceEntryChannels.publish( + { + kind: "entry-received", + entry, + }, + "recording" + ); + }); +}; + +// we should actually only feed it the information it needs to complete so we can support safari +type Task = { + completeInteraction: ( + entry: PerformanceEntryChannelEvent + ) => CompletedInteraction; + startDateTime: number; + endDateTime: number; + type: "keyboard" | "pointer"; + interactionUUID: string; +}; +export const MAX_INTERACTION_TASKS = 25; + +let tasks = new BoundedArray(MAX_INTERACTION_TASKS); + +const getAssociatedDetailedTimingInteraction = ( + entry: PerformanceInteraction, + activeTasks: Array +) => { + let closestTask: Task | null = null; + for (const task of activeTasks) { + if (task.type !== entry.type) { + continue; + } + + if (closestTask === null) { + closestTask = task; + continue; + } + + const getAbsoluteDiff = (task: Task, entry: PerformanceInteraction) => + Math.abs(task.startDateTime) - (entry.startTime + entry.timeOrigin); + + if (getAbsoluteDiff(task, entry) < getAbsoluteDiff(closestTask, entry)) { + closestTask = task; + } + } + + return closestTask; +}; + +// this would be cool if it listened for merge, so it had to be after +export const listenForPerformanceEntryInteractions = ( + onComplete: (completedInteraction: CompletedInteraction) => void +) => { + // we make the assumption that the detailed timing will be ready before the performance timing + const unsubscribe = performanceEntryChannels.subscribe( + "recording", + (event) => { + const associatedDetailedInteraction = + event.kind === "auto-complete-race" + ? tasks.find((task) => task.interactionUUID === event.interactionUUID) + : getAssociatedDetailedTimingInteraction(event.entry, tasks); + + // REMINDME: this likely means we clicked a non interactable thing but our handler still ran + // so we shouldn't treat this as an invariant, but instead use it to verify if we clicked + // something interactable + if (!associatedDetailedInteraction) { + return; + } + + const completedInteraction = + associatedDetailedInteraction.completeInteraction(event); + onComplete(completedInteraction); + } + ); + + return unsubscribe; +}; + +type ShouldContinue = boolean; +const trackDetailedTiming = ({ + onMicroTask, + onRAF, + onTimeout, + abort, +}: { + onMicroTask: () => ShouldContinue; + onRAF: () => ShouldContinue; + onTimeout: () => void; + abort?: () => boolean; +}) => { + queueMicrotask(() => { + if (abort?.() === true) { + return; + } + + if (!onMicroTask()) { + return; + } + requestAnimationFrame(() => { + if (abort?.() === true) { + return; + } + if (!onRAF()) { + return; + } + setTimeout(() => { + if (abort?.() === true) { + return; + } + onTimeout(); + }, 0); + }); + }); +}; + +const getTargetInteractionDetails = (target: Element) => { + const associatedFiber = getFiberFromElement(target); + if (!associatedFiber) { + return; + } + + // TODO: if element is minified, squash upwards till first non minified ancestor, and set name as ChildOf() + let componentName = associatedFiber + ? getDisplayName(associatedFiber?.type) + : "N/A"; + + if (!componentName) { + componentName = + getFirstNameFromAncestor(associatedFiber, (name) => name.length > 2) ?? + "N/A"; + } + + if (!componentName) { + return; + } + + const componentPath = getInteractionPath(associatedFiber); + // const childrenTree = collectFiberSubtree(associatedFiber, 20); // this can be expensive if not limited + + // const firstChildSvg = Object.entries(childrenTree).find(([name, {isSvg }]) => isSvg) + + // const firstSvg = + // associatedFiber.type === "svg" + // ? getFirstNameFromAncestor(associatedFiber) + // : Object.entries(childrenTree).find(([name, {isSvg }]) => isSvg) + + // lowkey i have an idea + return { + componentPath, + childrenTree: {}, + componentName, + }; +}; + +type LastInteractionRef = { + current: ( + | InteractionStartStage + | JSEndStage + | RAFStage + | TimeoutStage + | UnInitializedStage + ) & { stageStart: number }; +}; + +/** + * + * handles tracking event timings for arbitrarily overlapping handlers with cancel logic + */ +export const setupDetailedPointerTimingListener = ( + kind: "pointer" | "keyboard", + options: { + onStart?: (interactionUUID: string) => void; + onComplete?: ( + interactionUUID: string, + finalInteraction: { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; + flushNeeded: boolean; + }, + entry: PerformanceEntryChannelEvent + ) => void; + onError?: (interactionUUID: string) => void; + } +) => { + let instrumentationIdInControl: string | null = null; + + const getEvent = ( + info: { phase: "start" } | { phase: "end"; target: Element } + ) => { + switch (kind) { + case "pointer": { + if (info.phase === "start") { + return "pointerup"; + } + if ( + info.target instanceof HTMLInputElement || + info.target instanceof HTMLSelectElement + ) { + return "change"; + } + return "click"; + } + case "keyboard": { + if (info.phase === "start") { + return "keydown"; + } + + return "change"; + } + } + }; + + const lastInteractionRef: LastInteractionRef = { + current: { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), // the first interaction uses this + stageStart: Date.now(), + interactionType: kind, + }, + }; + + const onInteractionStart = (e: Event) => { + const path = e.composedPath(); + if ( + path.some( + (el) => el instanceof Element && el.id === "react-scan-toolbar-root" + ) + ) { + return; + } + if (Date.now() - lastInteractionRef.current.stageStart > 2000) { + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + } + + if (lastInteractionRef.current.kind !== "uninitialized-stage") { + return; + } + + const pointerUpStart = performance.now(); + + options?.onStart?.(lastInteractionRef.current.interactionUUID); + const details = getTargetInteractionDetails(e.target as HTMLElement); + + if (!details) { + options?.onError?.(lastInteractionRef.current.interactionUUID); + return; + } + + const fiberRenders: InteractionStartStage["fiberRenders"] = {}; + const stopListeningForRenders = listenForRenders(fiberRenders); + lastInteractionRef.current = { + ...lastInteractionRef.current, + interactionType: kind, + blockingTimeStart: Date.now(), + childrenTree: details.childrenTree, + componentName: details.componentName, + componentPath: details.componentPath, + fiberRenders, + kind: "interaction-start", + interactionStartDetail: pointerUpStart, + stopListeningForRenders, + }; + + const event = getEvent({ phase: "end", target: e.target as Element }); + document.addEventListener(event, onLastJS as any, { + once: true, + }); + + // this is an edge case where a click event is not fired after a pointerdown + // im not sure why this happens, but it seems to only happen on non intractable elements + // it causes the event handler to stay alive until a future interaction, which can break timing (looks super long) + // or invariants (the start metadata was removed, so now its an end metadata with no start) + requestAnimationFrame(() => { + document.removeEventListener(event as any, onLastJS as any); + }); + }; + + document.addEventListener( + getEvent({ phase: "start" }), + onInteractionStart as any, + { + capture: true, + } + ); + + /** + * + * TODO: IF WE DETECT RENDERS DURING THIS PERIOD WE CAN INCLUDE THAT IN THE RESULT AND THEN BACK THAT OUT OF COMPUTED STYLE TIME AND ADD IT BACK INTO JS TIME + */ + const onLastJS = ( + e: { target: Element }, + instrumentationId: string, + abort: () => boolean + ) => { + if ( + lastInteractionRef.current.kind !== "interaction-start" && + instrumentationId === instrumentationIdInControl + ) { + if (kind === "pointer" && e.target instanceof HTMLSelectElement) { + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + return; + } + + options?.onError?.(lastInteractionRef.current.interactionUUID); + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + invariantError("pointer -> click"); + return; + } + + instrumentationIdInControl = instrumentationId; + + trackDetailedTiming({ + abort, + onMicroTask: () => { + if (lastInteractionRef.current.kind === "uninitialized-stage") { + return false; + } + + lastInteractionRef.current = { + ...lastInteractionRef.current, + kind: "js-end-stage", + jsEndDetail: performance.now(), + }; + return true; + }, + onRAF: () => { + if ( + lastInteractionRef.current.kind !== "js-end-stage" && + lastInteractionRef.current.kind !== "raf-stage" + ) { + options?.onError?.(lastInteractionRef.current.interactionUUID); + invariantError("bad transition to raf"); + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + return false; + } + + lastInteractionRef.current = { + ...lastInteractionRef.current, + kind: "raf-stage", + rafStart: performance.now(), + }; + + return true; + }, + onTimeout: () => { + if (lastInteractionRef.current.kind !== "raf-stage") { + options?.onError?.(lastInteractionRef.current.interactionUUID); + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + invariantError("raf->timeout"); + return; + } + const now = Date.now(); + const timeoutStage: TimeoutStage = Object.freeze({ + ...lastInteractionRef.current, + kind: "timeout-stage", + blockingTimeEnd: now, + commitEnd: performance.now(), + }); + + lastInteractionRef.current = { + kind: "uninitialized-stage", + interactionUUID: crypto.randomUUID(), + stageStart: now, + interactionType: kind, + }; + + const completeInteraction = (event: PerformanceEntryChannelEvent) => { + const latency = + event.kind === "auto-complete-race" + ? event.detailedTiming.commitEnd - + event.detailedTiming.interactionStartDetail + : event.entry.latency; + const finalInteraction = { + detailedTiming: timeoutStage, + latency, + completedAt: Date.now(), + flushNeeded: true, + }; + options?.onComplete?.( + timeoutStage.interactionUUID, + finalInteraction, + event + ); + + return finalInteraction; + }; + tasks.push({ + completeInteraction, + endDateTime: Date.now(), + startDateTime: timeoutStage.blockingTimeStart, + type: kind, + interactionUUID: timeoutStage.interactionUUID, + }); + + if (!isPerformanceEventAvailable()) { + completeInteraction({ + kind: "auto-complete-race", + // redundant + detailedTiming: timeoutStage, + interactionUUID: timeoutStage.interactionUUID, + }); + } else { + setTimeout(() => { + completeInteraction({ + kind: "auto-complete-race", + // redundant + detailedTiming: timeoutStage, + interactionUUID: timeoutStage.interactionUUID, + }); + const newTasks = tasks.filter( + (task) => task.interactionUUID !== timeoutStage.interactionUUID + ); + tasks = BoundedArray.fromArray(newTasks, MAX_INTERACTION_TASKS); + // this means the max frame presentation delta we can observe is 300ms, but this should catch >99% of cases, the trade off is to not accidentally miss slowdowns if the user quickly clicked something else while this race was happening + }, 300); + } + }, + }); + }; + + const onKeyPress = (e: { target: Element }) => { + const id = crypto.randomUUID(); + onLastJS(e, id, () => id !== instrumentationIdInControl); + }; + + if (kind === "keyboard") { + document.addEventListener("keypress", onKeyPress as any); + } + + return () => { + document.removeEventListener( + getEvent({ phase: "start" }), + onInteractionStart as any, + { + capture: true, + } + ); + document.removeEventListener("keypress", onKeyPress as any); + }; +}; + +// unused, but will be soon for monitoring +export const collectFiberSubtree = ( + fiber: Fiber, + limit: number +): Record< + string, + { + children: Array; + firstNamedAncestor: string; + isRoot: boolean; + isSvg: boolean; + } +> => { + const adjacencyList = createChildrenAdjacencyList(fiber, limit).entries(); + const fiberToNames = Array.from(adjacencyList).map( + ([fiber, { children, parent, isRoot, isSVG }]) => [ + getDisplayName(fiber.type) ?? "N/A", + { + children: children.map((fiber) => getDisplayName(fiber.type) ?? "N/A"), + firstNamedAncestor: parent + ? (getFirstNameFromAncestor(parent) ?? "No Parent") + : "No Parent", + isRoot, + isSVG, + }, + ] + ); + + return Object.fromEntries(fiberToNames); +}; + +const getHostFromFiber = (fiber: Fiber) => { + return traverseFiber(fiber, (node) => { + // shouldn't be too slow + if (isHostFiber(node)) { + return true; + } + })?.stateNode; +}; + +const isPerformanceEventAvailable = () => { + return "PerformanceEventTiming" in globalThis; +}; + +export const listenForRenders = ( + fiberRenders: InteractionStartStage["fiberRenders"] +) => { + const listener = (fiber: Fiber) => { + const displayName = getDisplayName(fiber.type); + if (!displayName) { + return; + } + const existing = fiberRenders[displayName]; + if (!existing) { + const parents = new Set(); + const parentCompositeName = getDisplayName( + getParentCompositeFiber(fiber) + ); + if (parentCompositeName) { + parents.add(parentCompositeName); + } + const { selfTime, totalTime } = getTimings(fiber); + + const newChanges = collectInspectorDataWithoutCounts(fiber); + const emptySection: SectionData = { + current: [], + changes: new Set(), + changesCounts: new Map(), + }; + const changes = { + fiberProps: newChanges.fiberProps || emptySection, + fiberState: newChanges.fiberState || emptySection, + fiberContext: newChanges.fiberContext || emptySection, + }; + fiberRenders[displayName] = { + renderCount: 1, + parents: parents, + selfTime, + totalTime, + nodeInfo: [ + { + element: getHostFromFiber(fiber), + name: getDisplayName(fiber.type) ?? "Unknown", + selfTime: getTimings(fiber).selfTime, + }, + ], + changes, + }; + + return; + } + const parentType = getParentCompositeFiber(fiber)?.[0]?.type; + if (parentType) { + const parentCompositeName = getDisplayName(parentType); + if (parentCompositeName) { + existing.parents.add(parentCompositeName); + } + } + const { selfTime, totalTime } = getTimings(fiber); + + const newChanges = collectInspectorDataWithoutCounts(fiber); + + if (!newChanges) return; + + const emptySection: SectionData = { + current: [], + changes: new Set(), + changesCounts: new Map(), + }; + + existing.changes = { + fiberProps: mergeSectionData( + existing.changes?.fiberProps || emptySection, + newChanges.fiberProps || emptySection + ), + fiberState: mergeSectionData( + existing.changes?.fiberState || emptySection, + newChanges.fiberState || emptySection + ), + fiberContext: mergeSectionData( + existing.changes?.fiberContext || emptySection, + newChanges.fiberContext || emptySection + ), + }; + + existing.renderCount += 1; + existing.selfTime += selfTime; + existing.totalTime += totalTime; + existing.nodeInfo.push({ + element: getHostFromFiber(fiber), + name: getDisplayName(fiber.type) ?? "Unknown", + selfTime: getTimings(fiber).selfTime, + }); + }; + Store.monitor.value!.interactionListeningForRenders = listener; + + return () => { + if (Store.monitor.value?.interactionListeningForRenders === listener) { + Store.monitor.value.interactionListeningForRenders = null; + } + }; +}; + +const mergeSectionData = ( + existing: SectionData, + newData: SectionData +): SectionData => { + const mergedSection: SectionData = { + current: [...existing.current], + changes: new Set(), + changesCounts: new Map(), + }; + + for (const value of newData.current) { + if (!mergedSection.current.some((item) => item.name === value.name)) { + mergedSection.current.push(value); + } + } + + for (const change of newData.changes) { + if (typeof change === "string" || typeof change === "number") { + mergedSection.changes.add(change); + const existingCount = existing.changesCounts.get(change) || 0; + const newCount = newData.changesCounts.get(change) || 0; + mergedSection.changesCounts.set(change, existingCount + newCount); + } + } + + return mergedSection; +}; diff --git a/packages/scan/src/core/notifications/types.ts b/packages/scan/src/core/notifications/types.ts new file mode 100644 index 00000000..ab3bc789 --- /dev/null +++ b/packages/scan/src/core/notifications/types.ts @@ -0,0 +1,36 @@ +export interface PerformanceInteractionEntry extends PerformanceEntry { + interactionId: string; + target: Element; + name: string; + duration: number; + startTime: number; + processingStart: number; + processingEnd: number; + entryType: string; +} +export interface PerformanceInteraction { + id: string; + latency: number; + entries: Array; + target: Element | null; + type: "pointer" | "keyboard"; + startTime: number; + endTime: number; + processingStart: number; + processingEnd: number; + duration: number; + inputDelay: number; + processingDuration: number; + presentationDelay: number; + timestamp: number; + timeSinceTabInactive: number | "never-hidden"; + visibilityState: DocumentVisibilityState; + timeOrigin: number; + referrer: string; + detailedTiming?: { + jsHandlersTime: number; // pointerup -> click + prePaintTime: number; // click -> RAF + paintTime: number; // RAF -> setTimeout + compositorTime: number; // remaining duration + }; +} diff --git a/packages/scan/src/core/utils.ts b/packages/scan/src/core/utils.ts index 80a37900..155647e4 100644 --- a/packages/scan/src/core/utils.ts +++ b/packages/scan/src/core/utils.ts @@ -1,13 +1,13 @@ // @ts-nocheck -import { type Fiber, getType } from 'bippy'; +import { type Fiber, getType } from "bippy"; // import type { ComponentType } from 'preact'; -import { ReactScanInternals } from '~core/index'; -import type { AggregatedRender } from '~web/utils/outline'; -import type { AggregatedChange, Render } from './instrumentation'; +import { ReactScanInternals } from "~core/index"; +import type { AggregatedRender } from "~web/utils/outline"; +import type { AggregatedChange, Render } from "./instrumentation"; export const aggregateChanges = ( changes: Array, - prevAggregatedChange?: AggregatedChange, + prevAggregatedChange?: AggregatedChange ) => { const newChange = { type: prevAggregatedChange?.type ?? 0, @@ -42,11 +42,11 @@ export const joinAggregations = ({ export const aggregateRender = ( newRender: Render, - prevAggregated: AggregatedRender, + prevAggregated: AggregatedRender ) => { prevAggregated.changes = aggregateChanges( newRender.changes, - prevAggregated.changes, + prevAggregated.changes ); prevAggregated.aggregatedCount += 1; prevAggregated.didCommit = prevAggregated.didCommit || newRender.didCommit; @@ -102,9 +102,9 @@ function componentGroupHasForget(group: ComponentData[]): boolean { } export const getLabelText = ( - groupedAggregatedRenders: Array, + groupedAggregatedRenders: Array ) => { - let labelText = ''; + let labelText = ""; const componentsByCount = new Map< number, @@ -137,7 +137,7 @@ export const getLabelText = ( cumulativeTime += totalTime; if (componentGroup.length > 4) { - text += '…'; + text += "…"; } if (count > 1) { @@ -151,7 +151,7 @@ export const getLabelText = ( parts.push(text); } - labelText = parts.join(', '); + labelText = parts.join(", "); if (!labelText.length) return null; @@ -169,7 +169,7 @@ export const getLabelText = ( export const updateFiberRenderData = (fiber: Fiber, renders: Array) => { ReactScanInternals.options.value.onRender?.(fiber, renders); const type = getType(fiber.type) || fiber.type; - if (type && typeof type === 'function' && typeof type === 'object') { + if (type && typeof type === "function" && typeof type === "object") { const renderData = (type.renderData || { count: 0, time: 0, @@ -196,3 +196,50 @@ export function isEqual(a: unknown, b: unknown): boolean { // biome-ignore lint/suspicious/noSelfCompare: reliable way to detect NaN values in JavaScript return a === b || (a !== a && b !== b); } + +export const playNotificationSound = (audioContext: AudioContext) => { + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + const options = { + type: "sine" as OscillatorType, + freq: [ + 392, + // 523.25, + 600 + // 659.25 + ], + duration: 0.3, + gain: 0.12, + }; + + const frequencies = options.freq; + const timePerNote = options.duration / frequencies.length; + + frequencies.forEach((freq, i) => { + oscillator.frequency.setValueAtTime( + freq, + audioContext.currentTime + i * timePerNote + ); + }); + + oscillator.type = options.type; + gainNode.gain.setValueAtTime(options.gain, audioContext.currentTime); + + gainNode.gain.setTargetAtTime( + 0, + audioContext.currentTime + options.duration * 0.7, + 0.05 + ); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + options.duration); +}; + + + + + diff --git a/packages/scan/src/new-outlines/index.ts b/packages/scan/src/new-outlines/index.ts index 6715f117..fdd06eb4 100644 --- a/packages/scan/src/new-outlines/index.ts +++ b/packages/scan/src/new-outlines/index.ts @@ -5,23 +5,23 @@ import { getFiberId, getNearestHostFibers, isCompositeFiber, -} from 'bippy'; -import { ReactScanInternals, Store, ignoredProps } from '~core/index'; -import { createInstrumentation } from '~core/instrumentation'; -import { inspectorUpdateSignal } from '~web/components/inspector/states'; -import { readLocalStorage, removeLocalStorage } from '~web/utils/helpers'; -import { log, logIntro } from '~web/utils/log'; +} from "bippy"; +import { ReactScanInternals, Store, ignoredProps } from "~core/index"; +import { createInstrumentation } from "~core/instrumentation"; +import { readLocalStorage, removeLocalStorage } from "~web/utils/helpers"; +import { log, logIntro } from "~web/utils/log"; +import { inspectorUpdateSignal } from "~web/views/inspector/states"; import { OUTLINE_ARRAY_SIZE, drawCanvas, initCanvas, updateOutlines, updateScroll, -} from './canvas'; -import type { ActiveOutline, BlueprintOutline, OutlineData } from './types'; +} from "./canvas"; +import type { ActiveOutline, BlueprintOutline, OutlineData } from "./types"; // The worker code will be replaced at build time -const workerCode = '__WORKER_CODE__'; +const workerCode = "__WORKER_CODE__"; let worker: Worker | null = null; let canvas: HTMLCanvasElement | null = null; @@ -33,10 +33,10 @@ const activeOutlines = new Map(); const blueprintMap = new Map(); const blueprintMapKeys = new Set(); -export const outlineFiber = (fiber: Fiber) => { +export const outlineFiber = (fiber: Fiber, renderedAt: number) => { if (!isCompositeFiber(fiber)) return; const name = - typeof fiber.type === 'string' ? fiber.type : getDisplayName(fiber); + typeof fiber.type === "string" ? fiber.type : getDisplayName(fiber); if (!name) return; const blueprint = blueprintMap.get(fiber); const nearestFibers = getNearestHostFibers(fiber); @@ -48,10 +48,12 @@ export const outlineFiber = (fiber: Fiber) => { count: 1, elements: nearestFibers.map((fiber) => fiber.stateNode), didCommit: didCommit ? 1 : 0, + renderedAt, }); blueprintMapKeys.add(fiber); } else { blueprint.count++; + blueprint.renderedAt = renderedAt; } }; @@ -83,9 +85,17 @@ const mergeRects = (rects: DOMRect[]) => { return new DOMRect(minX, minY, maxX - minX, maxY - minY); }; +const elementInvariant = (shouldBeEl: unknown) => { + if (!(shouldBeEl instanceof Element)) { + throw new Error("Element Invariant"); + } +}; + export const getBatchedRectMap = async function* ( - elements: Element[], + elements: Element[] ): AsyncGenerator { + elements.forEach(elementInvariant); + const uniqueElements = new Set(elements); const seenElements = new Set(); @@ -125,7 +135,7 @@ export const getBatchedRectMap = async function* ( const entries = await new Promise( (resolve) => { resolveNext = resolve; - }, + } ); if (entries.length > 0) { yield entries; @@ -134,7 +144,7 @@ export const getBatchedRectMap = async function* ( }; const SupportedArrayBuffer = - typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : ArrayBuffer; + typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : ArrayBuffer; export const flushOutlines = async () => { const elements: Element[] = []; @@ -187,7 +197,7 @@ export const flushOutlines = async () => { if (blueprints.length > 0) { const arrayBuffer = new SupportedArrayBuffer( - blueprints.length * OUTLINE_ARRAY_SIZE * 4, + blueprints.length * OUTLINE_ARRAY_SIZE * 4 ); const sharedView = new Float32Array(arrayBuffer); const blueprintNames = new Array(blueprints.length); @@ -226,7 +236,7 @@ export const flushOutlines = async () => { if (worker) { worker.postMessage({ - type: 'draw-outlines', + type: "draw-outlines", data: arrayBuffer, names: blueprintNames, }); @@ -260,7 +270,7 @@ const draw = () => { const CANVAS_HTML_STR = ``; const IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED = - typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined'; + typeof OffscreenCanvas !== "undefined" && typeof Worker !== "undefined"; const getDpr = () => { return Math.min(window.devicePixelRatio || 1, 2); @@ -268,9 +278,9 @@ const getDpr = () => { export const getCanvasEl = () => { cleanup(); - const host = document.createElement('div'); - host.setAttribute('data-react-scan', 'true'); - const shadowRoot = host.attachShadow({ mode: 'open' }); + const host = document.createElement("div"); + host.setAttribute("data-react-scan", "true"); + const shadowRoot = host.attachShadow({ mode: "open" }); shadowRoot.innerHTML = CANVAS_HTML_STR; const canvasEl = shadowRoot.firstChild as HTMLCanvasElement; @@ -289,31 +299,33 @@ export const getCanvasEl = () => { if (IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED) { try { - const useExtensionWorker = readLocalStorage('use-extension-worker'); - removeLocalStorage('use-extension-worker'); + const useExtensionWorker = readLocalStorage( + "use-extension-worker" + ); + removeLocalStorage("use-extension-worker"); if (useExtensionWorker) { worker = new Worker( URL.createObjectURL( - new Blob([workerCode], { type: 'application/javascript' }), - ), + new Blob([workerCode], { type: "application/javascript" }) + ) ); const offscreenCanvas = canvasEl.transferControlToOffscreen(); worker?.postMessage( { - type: 'init', + type: "init", canvas: offscreenCanvas, width: canvasEl.width, height: canvasEl.height, dpr, }, - [offscreenCanvas], + [offscreenCanvas] ); } } catch (e) { // biome-ignore lint/suspicious/noConsole: Intended debug output - console.warn('Failed to initialize OffscreenCanvas worker:', e); + console.warn("Failed to initialize OffscreenCanvas worker:", e); } } @@ -322,7 +334,7 @@ export const getCanvasEl = () => { } let isResizeScheduled = false; - window.addEventListener('resize', () => { + window.addEventListener("resize", () => { if (!isResizeScheduled) { isResizeScheduled = true; setTimeout(() => { @@ -333,7 +345,7 @@ export const getCanvasEl = () => { canvasEl.style.height = `${height}px`; if (worker) { worker.postMessage({ - type: 'resize', + type: "resize", width, height, dpr, @@ -356,7 +368,7 @@ export const getCanvasEl = () => { let prevScrollY = window.scrollY; let isScrollScheduled = false; - window.addEventListener('scroll', () => { + window.addEventListener("scroll", () => { if (!isScrollScheduled) { isScrollScheduled = true; setTimeout(() => { @@ -367,7 +379,7 @@ export const getCanvasEl = () => { prevScrollY = scrollY; if (worker) { worker.postMessage({ - type: 'scroll', + type: "scroll", deltaX, deltaY, }); @@ -403,7 +415,7 @@ export const stop = () => { }; export const cleanup = () => { - const host = document.querySelector('[data-react-scan]'); + const host = document.querySelector("[data-react-scan]"); if (host) { host.remove(); } @@ -428,50 +440,46 @@ export const isValidFiber = (fiber: Fiber) => { return true; }; -export const initReactScanInstrumentation = () => { +export const initReactScanInstrumentation = ({ + onActive, +}: { + onActive?: () => void; +}) => { if (hasStopped()) return; // todo: don't hardcode string getting weird ref error in iife when using process.env - const instrumentation = createInstrumentation('react-scan-devtools-0.1.0', { + const instrumentation = createInstrumentation("react-scan-devtools-0.1.0", { onCommitStart: () => { ReactScanInternals.options.value.onCommitStart?.(); }, - onActive: () => { - if (hasStopped()) return; - - const host = getCanvasEl(); - if (host) { - document.documentElement.appendChild(host); - } - globalThis.__REACT_SCAN__ = { - ReactScanInternals, - }; - startReportInterval(); - logIntro(); - }, - onError: () => { + onActive, + onError() { // todo: ingest errors without accidentally collecting data about user }, isValidFiber, - onRender: (fiber, renders) => { + onRender: (fiber, renders, renderedAt) => { + if (isCompositeFiber(fiber)) { + Store.monitor.value?.interactionListeningForRenders?.(fiber, renders); + } const isOverlayPaused = ReactScanInternals.instrumentation?.isPaused.value; const isInspectorInactive = - Store.inspectState.value.kind === 'inspect-off' || - Store.inspectState.value.kind === 'uninitialized'; + Store.inspectState.value.kind === "inspect-off" || + Store.inspectState.value.kind === "uninitialized"; const shouldFullyAbort = isOverlayPaused && isInspectorInactive; if (shouldFullyAbort) { return; } + if (!isOverlayPaused) { - outlineFiber(fiber); + outlineFiber(fiber, renderedAt); } if (ReactScanInternals.options.value.log) { // this can be expensive given enough re-renders log(renders); } - if (Store.inspectState.value.kind === 'focused') { + if (Store.inspectState.value.kind === "focused") { inspectorUpdateSignal.value = Date.now(); } diff --git a/packages/scan/src/new-outlines/types.ts b/packages/scan/src/new-outlines/types.ts index b6f79387..c31e344e 100644 --- a/packages/scan/src/new-outlines/types.ts +++ b/packages/scan/src/new-outlines/types.ts @@ -61,8 +61,8 @@ export interface BlueprintOutline { count: number; elements: Element[]; didCommit: 1 | 0; + renderedAt: number } - declare global { var __REACT_SCAN_STOP__: boolean; var ReactScan: { diff --git a/packages/scan/src/web/assets/svgs/svgs.ts b/packages/scan/src/web/assets/svgs/svgs.ts index 58c64480..1cf5aeae 100644 --- a/packages/scan/src/web/assets/svgs/svgs.ts +++ b/packages/scan/src/web/assets/svgs/svgs.ts @@ -98,5 +98,7 @@ export const ICONS = ` + + `; diff --git a/packages/scan/src/web/components/widget/fps-meter.tsx b/packages/scan/src/web/components/widget/fps-meter.tsx deleted file mode 100644 index e94532e1..00000000 --- a/packages/scan/src/web/components/widget/fps-meter.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useRef } from 'preact/hooks'; -import { getFPS } from '~core/instrumentation'; -import { cn } from '~web/utils/helpers'; - -export const FpsMeter = () => { - const refFps = useRef(null); - - useEffect(() => { - const intervalId = setInterval(() => { - const fps = getFPS(); - let color = '#fff'; - if (fps) { - if (fps < 30) color = '#f87171'; - if (fps < 50) color = '#fbbf24'; - } - if (refFps.current) { - refFps.current.setAttribute('data-text', fps.toString()); - refFps.current.style.color = color; - } - }, 100); - - return () => clearInterval(intervalId); - }, []); - - return ( - - - - FPS - - - ); -}; - -export default FpsMeter; diff --git a/packages/scan/src/web/components/widget/resize-handle.tsx b/packages/scan/src/web/components/widget/resize-handle.tsx deleted file mode 100644 index 5b572376..00000000 --- a/packages/scan/src/web/components/widget/resize-handle.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import type { JSX } from 'preact'; -import { useCallback, useEffect, useRef } from 'preact/hooks'; -import { Store } from '~core/index'; -import { Icon } from '~web/components/icon'; -import { cn, saveLocalStorage } from '~web/utils/helpers'; -import { LOCALSTORAGE_KEY, MIN_CONTAINER_WIDTH, MIN_SIZE } from '../../constants'; -import { signalRefWidget, signalWidget } from '../../state'; -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); - - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps - useEffect(() => { - const container = refContainer.current; - if (!container) return; - - const updateVisibility = (isFocused: boolean) => { - const isVisible = - isFocused && - 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(Store.inspectState.value.kind === 'focused'); - - prevWidth.current = state.dimensions.width; - prevHeight.current = state.dimensions.height; - prevCorner.current = state.corner; - }); - - const unsubscribeStoreInspectState = Store.inspectState.subscribe( - (state) => { - updateVisibility(state.kind === 'focused'); - }, - ); - - return () => { - unsubscribeSignalWidget(); - unsubscribeStoreInspectState(); - prevWidth.current = null; - prevHeight.current = null; - prevCorner.current = null; - }; - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps - const handleResize = useCallback((e: JSX.TargetedMouseEvent) => { - 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 handleMouseMove = (e: MouseEvent) => { - 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_SIZE.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 handleMouseUp = () => { - if (rafId) { - cancelAnimationFrame(rafId); - rafId = null; - } - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - - 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('mousemove', handleMouseMove, { - passive: true, - }); - document.addEventListener('mouseup', handleMouseUp); - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: no deps - const handleDoubleClick = useCallback((e: JSX.TargetedMouseEvent) => { - 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.3s cubic-bezier(0.4, 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/src/web/components/widget/toolbar/index.tsx b/packages/scan/src/web/components/widget/toolbar/index.tsx deleted file mode 100644 index b856cabe..00000000 --- a/packages/scan/src/web/components/widget/toolbar/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -// @TODO: @pivanov - finish the pin functionality -import { useCallback, useEffect, useRef } from 'preact/hooks'; -import { - type LocalStorageOptions, - ReactScanInternals, - Store, -} from '~core/index'; -import { Icon } from '~web/components/icon'; -import { Toggle } from '~web/components/toggle'; -import FpsMeter from '~web/components/widget/fps-meter'; -import { signalIsSettingsOpen } from '~web/state'; -import { cn, readLocalStorage, saveLocalStorage } from '~web/utils/helpers'; -import { constant } from '~web/utils/preact/constant'; - -export const Toolbar = constant(() => { - const refSettingsButton = useRef(null); - // const [isPinned, setIsPinned] = useState(false); - // const [metadata, setMetadata] = useState(null); - - const inspectState = Store.inspectState; - const isInspectActive = inspectState.value.kind === 'inspecting'; - const isInspectFocused = inspectState.value.kind === 'focused'; - - const onToggleInspect = useCallback(() => { - const currentState = Store.inspectState.value; - - switch (currentState.kind) { - case 'inspecting': - Store.inspectState.value = { - kind: 'inspect-off', - }; - break; - case 'focused': - Store.inspectState.value = { - kind: 'inspect-off', - }; - break; - case 'inspect-off': - Store.inspectState.value = { - kind: 'inspecting', - hoveredDomElement: null, - }; - break; - case 'uninitialized': - break; - } - }, []); - - const onToggleActive = useCallback((e: Event) => { - 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, - }); - }, []); - - // const onToggleSettings = useCallback(() => { - // signalIsSettingsOpen.value = !signalIsSettingsOpen.value; - // }, []); - - useEffect(() => { - const unSubState = Store.inspectState.subscribe((state) => { - if (state.kind === 'uninitialized') { - Store.inspectState.value = { - kind: 'inspect-off', - }; - } - - // if (state.kind === 'focused' && state.fiber) { - // const pinned = readLocalStorage('react-scann-pinned'); - // setIsPinned(!!pinned); - - // const m = getFiberMetadata(state.fiber); - // if (m !== null) { - // setMetadata(m); - // } - // } - }); - - const unSubSettings = signalIsSettingsOpen.subscribe((state) => { - refSettingsButton.current?.classList.toggle('text-inspect', state); - }); - - return () => { - unSubState(); - unSubSettings(); - }; - }, []); - - // const onTogglePin = useCallback(() => { - // if (isPinned) { - // removeLocalStorage('react-scann-pinned'); - // setIsPinned(false); - // } else { - // saveLocalStorage('react-scann-pinned', metadata); - // setIsPinned(true); - // } - // }, [isPinned, metadata]); - - let inspectIcon = null; - let inspectColor = '#999'; - - if (isInspectActive) { - inspectIcon = ; - inspectColor = '#8e61e3'; - } else if (isInspectFocused) { - inspectIcon = ; - inspectColor = '#8e61e3'; - } else { - inspectIcon = ; - inspectColor = '#999'; - } - - return ( -
-
- - - - - {/* { - isInspectFocused && ( - - ) - } */} - - {/* */} - - {/* todo, only render arrows when inspecting element */} - - {/* i think i want to put wrap this with config if user doesn't want to see it (specifically robinhood) */} -
-
- react-scan - {/* this fps meter is bad we can improve it */} - -
-
- ); -}); diff --git a/packages/scan/src/web/constants.ts b/packages/scan/src/web/constants.ts index 0d05a97c..fd20e30e 100644 --- a/packages/scan/src/web/constants.ts +++ b/packages/scan/src/web/constants.ts @@ -1,10 +1,10 @@ export const SAFE_AREA = 24; export const MIN_SIZE = { - width: 480, - height: 36, - initialHeight: 36 * 10, + width: 550, + height: 350, + initialHeight: 400, } as const; export const MIN_CONTAINER_WIDTH = 240; -export const LOCALSTORAGE_KEY = 'react-scan-widget-settings'; +export const LOCALSTORAGE_KEY = "react-scan-widget-settings-v2"; diff --git a/packages/scan/src/web/state.ts b/packages/scan/src/web/state.ts index 3605b119..dd12715a 100644 --- a/packages/scan/src/web/state.ts +++ b/packages/scan/src/web/state.ts @@ -1,22 +1,29 @@ -import { signal } from '@preact/signals'; -import type { - Corner, - WidgetConfig, - WidgetSettings, -} from './components/widget/types'; +import { Signal, signal } from "@preact/signals"; import { LOCALSTORAGE_KEY, MIN_CONTAINER_WIDTH, MIN_SIZE, SAFE_AREA, -} from './constants'; -import { readLocalStorage, saveLocalStorage } from './utils/helpers'; +} from "./constants"; +import { readLocalStorage, saveLocalStorage } from "./utils/helpers"; +import { fadeOutHighlights } from "./views/notifications/render-bar-chart"; +import { DEBUG } from "./views/widget"; +import type { + Corner, + WidgetConfig, + WidgetSettings, +} from "./views/widget/types"; export const signalIsSettingsOpen = signal(false); export const signalRefWidget = signal(null); +export const signalNotificationsOpen = signal(DEBUG); +export const signalNotificationDismissed = signal(false); +export const signalLastDismissTime = signal(0); + +export const signalSettingsOpen = signal(false); export const defaultWidgetConfig = { - corner: 'top-left' as Corner, + corner: "top-left" as Corner, dimensions: { isFullWidth: false, isFullHeight: false, @@ -67,7 +74,7 @@ export const getInitialWidgetConfig = (): WidgetConfig => { export const signalWidget = signal(getInitialWidgetConfig()); export const updateDimensions = (): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; const { dimensions } = signalWidget.value; const { width, height, position } = dimensions; diff --git a/packages/scan/src/web/toolbar.tsx b/packages/scan/src/web/toolbar.tsx index 84d0023d..8294a993 100644 --- a/packages/scan/src/web/toolbar.tsx +++ b/packages/scan/src/web/toolbar.tsx @@ -1,14 +1,17 @@ import { Component, render } from 'preact'; -import { Icon } from './components/icon'; -import { Widget } from './components/widget'; +import { Icon } from './views/icon'; +import { Widget } from './views/widget'; - -export let scriptLevelToolbar: HTMLDivElement | null = null +export let scriptLevelToolbar: HTMLDivElement | null = null; class ToolbarErrorBoundary extends Component { - state: { hasError: boolean; error: Error | null } = { hasError: false, error: null }; + state: { hasError: boolean; error: Error | null } = { + hasError: false, + error: null, + }; static getDerivedStateFromError(error: Error) { + console.error(error); return { hasError: true, error }; } @@ -48,7 +51,7 @@ export const createToolbar = (root: ShadowRoot): HTMLElement => { const container = document.createElement('div'); container.id = 'react-scan-toolbar-root'; window.__REACT_SCAN_TOOLBAR_CONTAINER__ = container; - scriptLevelToolbar = container + scriptLevelToolbar = container; root.appendChild(container); render( diff --git a/packages/scan/src/web/utils/helpers.ts b/packages/scan/src/web/utils/helpers.ts index d3f05f6a..daf3450d 100644 --- a/packages/scan/src/web/utils/helpers.ts +++ b/packages/scan/src/web/utils/helpers.ts @@ -6,24 +6,24 @@ import { SuspenseComponentTag, getDisplayName, hasMemoCache, -} from 'bippy'; -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +} from "bippy"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export const cn = (...inputs: Array): string => { return twMerge(clsx(inputs)); }; export const isFirefox = - typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox'); + typeof navigator !== "undefined" && navigator.userAgent.includes("Firefox"); export const onIdle = (callback: () => void) => { - if ('scheduler' in globalThis) { + if ("scheduler" in globalThis) { return globalThis.scheduler.postTask(callback, { - priority: 'background', + priority: "background", }); } - if ('requestIdleCallback' in window) { + if ("requestIdleCallback" in window) { return requestIdleCallback(callback); } return setTimeout(callback, 0); @@ -31,7 +31,7 @@ export const onIdle = (callback: () => void) => { export const throttle = ( callback: (e?: E) => void, - delay: number, + delay: number ): ((e?: E) => void) => { let lastCall = 0; return (e?: E) => { @@ -53,7 +53,7 @@ export const tryOrElse = (fn: () => T, defaultValue: T): T => { }; export const readLocalStorage = (storageKey: string): T | null => { - if (typeof window === 'undefined') return null; + if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(storageKey); @@ -64,14 +64,14 @@ export const readLocalStorage = (storageKey: string): T | null => { }; export const saveLocalStorage = (storageKey: string, state: T): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { window.localStorage.setItem(storageKey, JSON.stringify(state)); } catch {} }; export const removeLocalStorage = (storageKey: string): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { window.localStorage.removeItem(storageKey); @@ -80,7 +80,7 @@ export const removeLocalStorage = (storageKey: string): void => { export const toggleMultipleClasses = ( element: HTMLElement, - classes: Array, + classes: Array ) => { for (const cls of classes) { element.classList.toggle(cls); @@ -88,7 +88,7 @@ export const toggleMultipleClasses = ( }; interface WrapperBadge { - type: 'memo' | 'forwardRef' | 'lazy' | 'suspense' | 'profiler' | 'strict'; + type: "memo" | "forwardRef" | "lazy" | "suspense" | "profiler" | "strict"; title: string; compiler?: boolean; } @@ -99,14 +99,13 @@ export interface ExtendedDisplayName { 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', + name: "Unknown", wrappers: [], wrapperTypes: [], }; @@ -121,54 +120,55 @@ export const getExtendedDisplayName = (fiber: Fiber): ExtendedDisplayName => { hasMemoCache(fiber) || tag === SimpleMemoComponentTag || tag === MemoComponentTag || - (type as { $$typeof?: symbol })?.$$typeof === Symbol.for('react.memo') || + (type as { $$typeof?: symbol })?.$$typeof === Symbol.for("react.memo") || (elementType as { $$typeof?: symbol })?.$$typeof === - Symbol.for('react.memo') + Symbol.for("react.memo") ) { const compiler = hasMemoCache(fiber); wrapperTypes.push({ - type: 'memo', + 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', + ? "This component has been auto-memoized by the React Compiler." + : "Memoized component that skips re-renders if props are the same", compiler, }); } - if ( - tag === ForwardRefTag || - (type as { $$typeof?: symbol })?.$$typeof === - Symbol.for('react.forward_ref') - ) { - wrapperTypes.push({ - type: 'forwardRef', - title: - 'Component that can forward refs to DOM elements or other components', - }); - } + // temporary disabled since it's noisy + // if ( + // tag === ForwardRefTag || + // (type as { $$typeof?: symbol })?.$$typeof === + // Symbol.for("react.forward_ref") + // ) { + // wrapperTypes.push({ + // type: "forwardRef", + // title: + // "Component that can forward refs to DOM elements or other components", + // }); + // } if (tag === LazyComponentTag) { wrapperTypes.push({ - type: 'lazy', - title: 'Lazily loaded component that supports code splitting', + 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', + type: "suspense", + title: "Component that can suspend while content is loading", }); } if (tag === ProfilerTag) { wrapperTypes.push({ - type: 'profiler', - title: 'Component that measures rendering performance', + type: "profiler", + title: "Component that measures rendering performance", }); } - if (typeof name === 'string') { + if (typeof name === "string") { const wrapperRegex = /^(\w+)\((.*)\)$/; let currentName = name; while (wrapperRegex.test(currentName)) { @@ -184,8 +184,46 @@ export const getExtendedDisplayName = (fiber: Fiber): ExtendedDisplayName => { } return { - name: name || 'Unknown', + name: name || "Unknown", wrappers, wrapperTypes, }; }; + +export function measureElementSize(element: HTMLElement): { + width: number; + height: number; +} { + const clone = element.cloneNode(true) as HTMLElement; + + Object.assign(clone.style, { + position: "absolute", + left: "-9999px", + top: "-9999px", + width: "auto", + height: "auto", + maxWidth: "none", + maxHeight: "none", + minWidth: "0", + minHeight: "0", + padding: "0", + border: "0", + display: "block", + contain: "content", + opacity: "0", + pointerEvents: "none", + zIndex: "-1", + }); + + document.body.appendChild(clone); + + const rect = clone.getBoundingClientRect(); + const size = { + width: rect.width, + height: rect.height, + }; + + document.body.removeChild(clone); + + return size; +} diff --git a/packages/scan/src/web/utils/pin.ts b/packages/scan/src/web/utils/pin.ts index 53754442..b737b0a6 100644 --- a/packages/scan/src/web/utils/pin.ts +++ b/packages/scan/src/web/utils/pin.ts @@ -1,7 +1,7 @@ -import type { Fiber } from 'bippy'; -import { Store } from '~core/index'; -import { findComponentDOMNode } from '~web/components/inspector/utils'; -import { readLocalStorage } from './helpers'; +import type { Fiber } from "bippy"; +import { Store } from "~core/index"; +import { findComponentDOMNode } from "~web/views/inspector/utils"; +import { readLocalStorage } from "./helpers"; export interface FiberMetadata { componentName: string; @@ -12,7 +12,7 @@ export interface FiberMetadata { propKeys: string[]; } -const metadata = readLocalStorage('react-scann-pinned'); +const metadata = readLocalStorage("react-scann-pinned"); export const getFiberPath = (fiber: Fiber): string => { const pathSegments: string[] = []; @@ -21,36 +21,36 @@ export const getFiberPath = (fiber: Fiber): string => { while (currentFiber) { const elementType = currentFiber.elementType; const name = - typeof elementType === 'function' + typeof elementType === "function" ? elementType.displayName || elementType.name - : typeof elementType === 'string' + : typeof elementType === "string" ? elementType - : 'Unknown'; + : "Unknown"; const index = - currentFiber.index !== undefined ? `[${currentFiber.index}]` : ''; + currentFiber.index !== undefined ? `[${currentFiber.index}]` : ""; pathSegments.unshift(`${name}${index}`); currentFiber = currentFiber.return ?? null; } - return pathSegments.join('::'); + return pathSegments.join("::"); }; export const getFiberMetadata = (fiber: Fiber): FiberMetadata | null => { if (!fiber || !fiber.elementType) return null; - const componentName = fiber.elementType.name || 'UnknownComponent'; + const componentName = fiber.elementType.name || "UnknownComponent"; const position = fiber.index !== undefined ? fiber.index : -1; const sibling = fiber.sibling?.elementType?.name || null; let parentFiber = fiber.return; - let parent = 'Root'; + let parent = "Root"; while (parentFiber) { const parentName = parentFiber.elementType?.name; - if (typeof parentName === 'string' && parentName.trim().length > 0) { + if (typeof parentName === "string" && parentName.trim().length > 0) { parent = parentName; break; } @@ -61,7 +61,7 @@ export const getFiberMetadata = (fiber: Fiber): FiberMetadata | null => { const path = getFiberPath(fiber); const propKeys = fiber.pendingProps - ? Object.keys(fiber.pendingProps).filter((key) => key !== 'children') + ? Object.keys(fiber.pendingProps).filter((key) => key !== "children") : []; return { componentName, parent, position, sibling, path, propKeys }; @@ -73,7 +73,7 @@ const checkFiberMatch = (fiber: Fiber | undefined): boolean => { if (fiber.elementType.name !== metadata.componentName) return false; let currentParentFiber = fiber.return; - let parent = ''; + let parent = ""; while (currentParentFiber) { if (currentParentFiber.elementType?.name) { @@ -102,7 +102,7 @@ const processFiberQueue = (): void => { const fiber = fiberQueue.shift(); if (fiber && checkFiberMatch(fiber)) { // biome-ignore lint/suspicious/noConsole: Intended debug output - console.log('🎯 Pinned component found!', fiber); + console.log("🎯 Pinned component found!", fiber); isProcessing = false; const componentElement = findComponentDOMNode(fiber); @@ -110,7 +110,7 @@ const processFiberQueue = (): void => { if (!componentElement) return; Store.inspectState.value = { - kind: 'focused', + kind: "focused", focusedDomElement: componentElement, fiber, }; diff --git a/packages/scan/src/web/components/copy-to-clipboard/index.tsx b/packages/scan/src/web/views/copy-to-clipboard/index.tsx similarity index 100% rename from packages/scan/src/web/components/copy-to-clipboard/index.tsx rename to packages/scan/src/web/views/copy-to-clipboard/index.tsx diff --git a/packages/scan/src/web/components/icon/index.tsx b/packages/scan/src/web/views/icon/index.tsx similarity index 100% rename from packages/scan/src/web/components/icon/index.tsx rename to packages/scan/src/web/views/icon/index.tsx diff --git a/packages/scan/src/web/components/inspector/diff-value.tsx b/packages/scan/src/web/views/inspector/diff-value.tsx similarity index 100% rename from packages/scan/src/web/components/inspector/diff-value.tsx rename to packages/scan/src/web/views/inspector/diff-value.tsx diff --git a/packages/scan/src/web/components/inspector/flash-overlay.ts b/packages/scan/src/web/views/inspector/flash-overlay.ts similarity index 100% rename from packages/scan/src/web/components/inspector/flash-overlay.ts rename to packages/scan/src/web/views/inspector/flash-overlay.ts diff --git a/packages/scan/src/web/components/inspector/index.tsx b/packages/scan/src/web/views/inspector/index.tsx similarity index 95% rename from packages/scan/src/web/components/inspector/index.tsx rename to packages/scan/src/web/views/inspector/index.tsx index 909b64b4..fdd2d79f 100644 --- a/packages/scan/src/web/components/inspector/index.tsx +++ b/packages/scan/src/web/views/inspector/index.tsx @@ -14,7 +14,11 @@ import { inspectorUpdateSignal, timelineActions, } from './states'; -import { collectInspectorData, getStateNames, resetTracking } from './timeline/utils'; +import { + collectInspectorData, + getStateNames, + resetTracking, +} from './timeline/utils'; import { extractMinimalFiberInfo, getCompositeFiberFromElement } from './utils'; import { WhatChangedSection } from './what-changed'; @@ -30,7 +34,6 @@ export const globalInspectorState = { }, }; -// todo: add reset button and error message class InspectorErrorBoundary extends Component { state: { error: Error | null; hasError: boolean } = { hasError: false, @@ -111,13 +114,13 @@ export const Inspector = constant(() => { const { parentCompositeFiber } = getCompositeFiberFromElement( state.focusedDomElement, - state.fiber + state.fiber, ); - if (!parentCompositeFiber) return; - const isNewComponent = refLastInspectedFiber.current?.type !== parentCompositeFiber.type; + const isNewComponent = + refLastInspectedFiber.current?.type !== parentCompositeFiber.type; if (isNewComponent) { refLastInspectedFiber.current = parentCompositeFiber; @@ -136,7 +139,7 @@ export const Inspector = constant(() => { const { parentCompositeFiber } = getCompositeFiberFromElement( inspectState.focusedDomElement, - inspectState.fiber + inspectState.fiber, ); if (!parentCompositeFiber) { diff --git a/packages/scan/src/web/components/inspector/logging.ts b/packages/scan/src/web/views/inspector/logging.ts similarity index 100% rename from packages/scan/src/web/components/inspector/logging.ts rename to packages/scan/src/web/views/inspector/logging.ts diff --git a/packages/scan/src/web/components/inspector/overlay/index.tsx b/packages/scan/src/web/views/inspector/overlay/index.tsx similarity index 99% rename from packages/scan/src/web/components/inspector/overlay/index.tsx rename to packages/scan/src/web/views/inspector/overlay/index.tsx index 5c21354d..74ed7f6f 100644 --- a/packages/scan/src/web/components/inspector/overlay/index.tsx +++ b/packages/scan/src/web/views/inspector/overlay/index.tsx @@ -1,16 +1,16 @@ import { type Fiber, getDisplayName } from 'bippy'; import { useEffect, useRef } from 'preact/hooks'; import { ReactScanInternals, Store } from '~core/index'; +import { signalIsSettingsOpen } from '~web/state'; +import { cn, throttle } from '~web/utils/helpers'; +import { lerp } from '~web/utils/lerp'; import { type States, findComponentDOMNode, getAssociatedFiberRect, getCompositeComponentFromElement, nonVisualTags, -} from '~web/components/inspector/utils'; -import { signalIsSettingsOpen } from '~web/state'; -import { cn, throttle } from '~web/utils/helpers'; -import { lerp } from '~web/utils/lerp'; +} from '~web/views/inspector/utils'; type DrawKind = 'locked' | 'inspecting'; diff --git a/packages/scan/src/web/components/inspector/properties.tsx b/packages/scan/src/web/views/inspector/properties.tsx similarity index 90% rename from packages/scan/src/web/components/inspector/properties.tsx rename to packages/scan/src/web/views/inspector/properties.tsx index 2f5f1e11..6f5a6104 100644 --- a/packages/scan/src/web/components/inspector/properties.tsx +++ b/packages/scan/src/web/views/inspector/properties.tsx @@ -8,10 +8,10 @@ import { } from 'preact/hooks'; import { isEqual } from '~core/utils'; -import { CopyToClipboard } from '~web/components/copy-to-clipboard'; -import { Icon } from '~web/components/icon'; import { useMergedRefs } from '~web/hooks/use-merged-refs'; import { cn, tryOrElse } from '~web/utils/helpers'; +import { CopyToClipboard } from '~web/views/copy-to-clipboard'; +import { Icon } from '~web/views/icon'; import { globalInspectorState } from '.'; import { flashManager } from './flash-overlay'; import { timelineState } from './states'; @@ -51,7 +51,9 @@ interface PropertyElementProps { } interface PropertySectionProps { - refSticky?: ReturnType> | ((node: HTMLElement | null) => void); + refSticky?: + | ReturnType> + | ((node: HTMLElement | null) => void); isSticky?: boolean; section: 'props' | 'state' | 'context'; } @@ -297,12 +299,16 @@ export const PropertyElement = ({ if (parentPath) { const parts = parentPath.split('.'); path = parts.filter( - (part) => part !== 'props' && part !== getDisplayName(latestFiber.type), + (part) => + part !== 'props' && part !== getDisplayName(latestFiber.type), ); path.push(name); - currentValue = path.reduce((obj: Record, key) => - obj && typeof obj === 'object' ? (obj[key] as Record) : {}, - currentProps as Record + currentValue = path.reduce( + (obj: Record, key) => + obj && typeof obj === 'object' + ? (obj[key] as Record) + : {}, + currentProps as Record, ); } else { path = [name]; @@ -323,7 +329,8 @@ export const PropertyElement = ({ if (!parentPath) { const stateNames = currentUpdate.stateNames; const namedStateIndex = stateNames.indexOf(name); - const hookId = namedStateIndex !== -1 ? namedStateIndex.toString() : name; + const hookId = + namedStateIndex !== -1 ? namedStateIndex.toString() : name; overrideHookState(latestFiber, hookId, [], value); } else { const fullPathParts = parentPath.split('.'); @@ -334,15 +341,20 @@ export const PropertyElement = ({ const baseStateKey = statePath[0]; const stateNames = currentUpdate.stateNames; const namedStateIndex = stateNames.indexOf(baseStateKey); - const hookId = namedStateIndex !== -1 ? namedStateIndex.toString() : '0'; + const hookId = + namedStateIndex !== -1 ? namedStateIndex.toString() : '0'; const currentState = currentUpdate.state.current; - if (!currentState || !currentState.find(item => item.name === Number(baseStateKey))) { + if ( + !currentState || + !currentState.find((item) => item.name === Number(baseStateKey)) + ) { return; } const updatedState = updateNestedValue( - currentState.find(item => item.name === Number(baseStateKey))?.value, + currentState.find((item) => item.name === Number(baseStateKey)) + ?.value, statePath.slice(1).concat(name), value, ); @@ -589,7 +601,7 @@ export const PropertySection = ({ }, [section, currentIndex, updates]); const toggleExpanded = useCallback(() => { - setIsExpanded(state => { + setIsExpanded((state) => { if (isSticky && isExpanded) { return state; } @@ -623,12 +635,10 @@ export const PropertySection = ({ @@ -637,37 +647,34 @@ export const PropertySection = ({
{Array.isArray(currentData) ? currentData.map(({ name, value }) => ( - - )) + + )) : Object.entries(currentData).map(([key, value]) => ( - - ))} + + ))}
diff --git a/packages/scan/src/web/components/inspector/states.ts b/packages/scan/src/web/views/inspector/states.ts similarity index 100% rename from packages/scan/src/web/components/inspector/states.ts rename to packages/scan/src/web/views/inspector/states.ts diff --git a/packages/scan/src/web/components/inspector/timeline/index.tsx b/packages/scan/src/web/views/inspector/timeline/index.tsx similarity index 69% rename from packages/scan/src/web/components/inspector/timeline/index.tsx rename to packages/scan/src/web/views/inspector/timeline/index.tsx index 41662547..01086782 100644 --- a/packages/scan/src/web/components/inspector/timeline/index.tsx +++ b/packages/scan/src/web/views/inspector/timeline/index.tsx @@ -1,13 +1,10 @@ import { isInstrumentationActive } from 'bippy'; import { memo } from 'preact/compat'; import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks'; -import { Icon } from '~web/components/icon'; import type { useMergedRefs } from '~web/hooks/use-merged-refs'; +import { Icon } from '~web/views/icon'; import { Slider } from '../../slider'; -import { - timelineActions, - timelineState, -} from '../states'; +import { timelineActions, timelineState } from '../states'; import { calculateSliderValues } from '../utils'; interface TimelineProps { @@ -16,18 +13,12 @@ interface TimelineProps { | ((node: HTMLElement | null) => void); } -export const Timeline = memo(({ - refSticky, -}: TimelineProps) => { +export const Timeline = memo(({ refSticky }: TimelineProps) => { const refPlayInterval = useRef(null); const refChangeInterval = useRef(null); - const { - currentIndex, - isVisible, - totalUpdates, - updates, - } = timelineState.value; + const { currentIndex, isVisible, totalUpdates, updates } = + timelineState.value; const sliderValues = useMemo(() => { return calculateSliderValues(totalUpdates, currentIndex); @@ -39,7 +30,6 @@ export const Timeline = memo(({ const newIndex = Math.min(updates.length - 1, Math.max(0, value)); - let isViewingHistory = false; if (newIndex > 0 && newIndex < updates.length - 1) { isViewingHistory = true; @@ -93,33 +83,29 @@ export const Timeline = memo(({ - { - isVisible - ? ( - <> -
- {sliderValues.leftValue} -
- -
- {sliderValues.rightValue} -
- - ) - : 'View Re-renders History' - } + {isVisible ? ( + <> +
{sliderValues.leftValue}
+ +
{sliderValues.rightValue}
+ + ) : ( + 'View Change History' + )} ); }); diff --git a/packages/scan/src/web/components/inspector/timeline/utils.ts b/packages/scan/src/web/views/inspector/timeline/utils.ts similarity index 71% rename from packages/scan/src/web/components/inspector/timeline/utils.ts rename to packages/scan/src/web/views/inspector/timeline/utils.ts index 1c7032a8..a0df5ead 100644 --- a/packages/scan/src/web/components/inspector/timeline/utils.ts +++ b/packages/scan/src/web/views/inspector/timeline/utils.ts @@ -7,9 +7,9 @@ import { MemoComponentTag, type MemoizedState, SimpleMemoComponentTag, -} from 'bippy'; -import { isEqual } from '~core/utils'; -import { getChangedPropsDetailed, isPromise } from '../utils'; +} from "bippy"; +import { isEqual } from "~core/utils"; +import { getChangedPropsDetailed, isPromise } from "../utils"; interface ChangeTrackingInfo { count: number; @@ -20,6 +20,7 @@ interface ChangeTrackingInfo { type ChangeKey = string | number; +// AHFDSKJAHFKLASDJF WHAT ARE YOU DOING PAVEL const propsTracker = new Map(); const stateTracker = new Map(); const contextTracker = new Map(); @@ -29,11 +30,11 @@ const STATE_NAME_REGEX = /\[(?\w+),\s*set\w+\]/g; const PROPS_ORDER_REGEX = /\(\s*{\s*(?[^}]+)\s*}\s*\)/; export const getStateNames = (fiber: Fiber): Array => { - const componentSource = fiber.type?.toString?.() || ''; + const componentSource = fiber.type?.toString?.() || ""; return componentSource ? Array.from( componentSource.matchAll(STATE_NAME_REGEX), - (m: RegExpMatchArray) => m.groups?.name ?? '', + (m: RegExpMatchArray) => m.groups?.name ?? "" ) : []; }; @@ -55,7 +56,7 @@ export const trackChange = ( tracker: Map, key: ChangeKey, currentValue: unknown, - previousValue: unknown, + previousValue: unknown ): { hasChanged: boolean; count: number } => { const existing = tracker.get(key); const isInitialValue = tracker === propsTracker || tracker === contextTracker; @@ -94,8 +95,10 @@ export { propsTracker, stateTracker, contextTracker }; export interface SectionData { current: Array<{ name: string | number; value: unknown }>; - changes: Set; changesCounts: Map; + changes: Set; + // if i had stats her maybe its trivial lets see + // test: never; } export interface InspectorData { @@ -105,7 +108,7 @@ export interface InspectorData { } export const getStateFromFiber = ( - fiber: Fiber, + fiber: Fiber ): Record => { if (!fiber) return {}; @@ -138,13 +141,13 @@ export const getStateFromFiber = ( }; const getPropsOrder = (fiber: Fiber): Array => { - const componentSource = fiber.type?.toString?.() || ''; + const componentSource = fiber.type?.toString?.() || ""; const match = componentSource.match(PROPS_ORDER_REGEX); if (!match?.groups?.props) return []; return match.groups.props - .split(',') - .map((prop: string) => prop.trim().split(':')[0].split('=')[0].trim()) + .split(",") + .map((prop: string) => prop.trim().split(":")[0].split("=")[0].trim()) .filter(Boolean); }; @@ -179,7 +182,7 @@ interface CollectorResult { } export const collectPropsChanges = ( - fiber: Fiber, + fiber: Fiber ): CollectorResult => { const currentProps = fiber.memoizedProps || {}; const prevProps = fiber.alternate?.memoizedProps || {}; @@ -208,7 +211,7 @@ export const collectPropsChanges = ( }; export const collectStateChanges = ( - fiber: Fiber, + fiber: Fiber ): CollectorResult => { const current = getStateFromFiber(fiber); const prev = fiber.alternate ? getStateFromFiber(fiber.alternate) : {}; @@ -229,8 +232,16 @@ export const collectStateChanges = ( }; export const collectContextChanges = ( - fiber: Fiber, + fiber: Fiber ): CollectorResult => { + // if (0 === 0) { + // return { + // current: {}, + // prev: {}, + // changes: [], + // }; + // } + const currentContexts = getAllFiberContexts(fiber); const prevContexts = fiber.alternate ? getAllFiberContexts(fiber.alternate) @@ -264,9 +275,95 @@ export const collectContextChanges = ( } } + // console.log({current,prev,changes}); + return { current, prev, changes }; }; +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); + } + + return { + // data: { + fiberProps: propsData, + fiberState: stateData, + fiberContext: contextData, + // }, + }; +}; + export const collectInspectorData = (fiber: Fiber): InspectorDataResult => { const emptySection = (): SectionData => ({ current: [], @@ -296,17 +393,19 @@ export const collectInspectorData = (fiber: Fiber): InspectorDataResult => { propsData.current.push({ name: key, value: isPromise(value) - ? { type: 'promise', displayValue: 'Promise' } + ? { type: "promise", displayValue: "Promise" } : value, }); } + // so i guess needs to be for each fiber that's it + // there's no way this is stateful, right? for (const change of changes) { const { hasChanged, count } = trackChange( propsTracker, change.name, change.value, - change.prevValue, + change.prevValue ); if (hasChanged) { @@ -331,7 +430,7 @@ export const collectInspectorData = (fiber: Fiber): InspectorDataResult => { stateTracker, change.name, change.value, - change.prevValue, + change.prevValue ); if (hasChanged) { @@ -355,7 +454,7 @@ export const collectInspectorData = (fiber: Fiber): InspectorDataResult => { contextTracker, change.name, change.value, - change.prevValue, + change.prevValue ); if (hasChanged) { @@ -388,22 +487,39 @@ interface ContextInfo { 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, + fiber: Fiber ): Map => { - const contexts = new Map(); - if (!fiber) { - return contexts; + 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; + let contextItem: ContextDependency | null = + dependencies.firstContext; while (contextItem) { const memoizedValue = contextItem.memoizedValue; @@ -412,7 +528,7 @@ export const getAllFiberContexts = ( if (!contexts.has(memoizedValue)) { contexts.set(contextItem.context, { value: memoizedValue, - displayName: displayName ?? 'UnnamedContext', + displayName: displayName ?? "UnnamedContext", contextType: null, }); } @@ -428,5 +544,8 @@ export const getAllFiberContexts = ( currentFiber = currentFiber.return; } + // Cache the result for this fiber + fiberContextsCache.set(fiber, contexts); + return contexts; }; diff --git a/packages/scan/src/web/components/inspector/utils.ts b/packages/scan/src/web/views/inspector/utils.ts similarity index 98% rename from packages/scan/src/web/components/inspector/utils.ts rename to packages/scan/src/web/views/inspector/utils.ts index 07cf5904..f14eafda 100644 --- a/packages/scan/src/web/components/inspector/utils.ts +++ b/packages/scan/src/web/views/inspector/utils.ts @@ -1842,7 +1842,6 @@ export const replayComponent = async (fiber: Fiber): Promise => { 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 @@ -1852,18 +1851,15 @@ export const replayComponent = async (fiber: Fiber): Promise => { } 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 @@ -1874,7 +1870,6 @@ export const replayComponent = async (fiber: Fiber): Promise => { } catch {} } - // Then handle unnamed state hooks let hookIndex = 0; let currentHook = fiber.memoizedState; while (currentHook !== null) { @@ -1882,9 +1877,7 @@ export const replayComponent = async (fiber: Fiber): Promise => { 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 @@ -1899,26 +1892,21 @@ export const replayComponent = async (fiber: Fiber): Promise => { } } - // 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); @@ -1932,7 +1920,6 @@ export const replayComponent = async (fiber: Fiber): Promise => { } } - // Recursively handle children let child = fiber.child; while (child) { await replayComponent(child); diff --git a/packages/scan/src/web/components/inspector/what-changed.tsx b/packages/scan/src/web/views/inspector/what-changed.tsx similarity index 79% rename from packages/scan/src/web/components/inspector/what-changed.tsx rename to packages/scan/src/web/views/inspector/what-changed.tsx index aaf8911a..af17b1fd 100644 --- a/packages/scan/src/web/components/inspector/what-changed.tsx +++ b/packages/scan/src/web/views/inspector/what-changed.tsx @@ -8,16 +8,21 @@ import { useState, } from 'preact/hooks'; import { isEqual } from '~core/utils'; -import { CopyToClipboard } from '~web/components/copy-to-clipboard'; -import { Icon } from '~web/components/icon'; import type { useMergedRefs } from '~web/hooks/use-merged-refs'; import { cn } from '~web/utils/helpers'; import { throttle } from '~web/utils/helpers'; +import { CopyToClipboard } from '~web/views/copy-to-clipboard'; +import { Icon } from '~web/views/icon'; import { StickySection } from '../sticky-section'; import { DiffValueView } from './diff-value'; import { type MinimalFiberInfo, timelineState } from './states'; import { Timeline } from './timeline'; -import { formatFunctionPreview, formatPath, getObjectDiff, isPromise } from './utils'; +import { + formatFunctionPreview, + formatPath, + getObjectDiff, + isPromise, +} from './utils'; export type Setter = Dispatch>; @@ -52,8 +57,8 @@ const safeGetValue = (value: unknown): { value: unknown; error?: string } => { interface WhatChangedProps { isSticky?: boolean; refSticky?: - | ReturnType> - | ((node: HTMLElement | null) => void); + | ReturnType> + | ((node: HTMLElement | null) => void); calculateStickyTop: (removeSticky?: boolean) => void; shouldShowChanges: boolean; } @@ -90,53 +95,44 @@ export const WhatChangedSection = memo(() => { return ( <> - { - refShowTimeline.current && ( - - {(props) => ( - - )} - - ) - } + {refShowTimeline.current && ( + {(props) => } + )} {(props) => ( - + )} ); }); -export const WhatChanged = memo(({ - isSticky, - refSticky, - calculateStickyTop, - shouldShowChanges, -}: WhatChangedProps) => { - const [isExpanded, setIsExpanded] = useState(true); +export const WhatChanged = memo( + ({ + isSticky, + refSticky, + calculateStickyTop, + shouldShowChanges, + }: WhatChangedProps) => { + const [isExpanded, setIsExpanded] = useState(true); - return ( - <> - -
-
- { - shouldShowChanges && ( + return ( + <> + +
+
+ {shouldShowChanges && (
- ) - } + )} +
-
- - ); -}); + + ); + }, +); const renderStateName = (key: string, componentName: string) => { if (Number.isNaN(Number(key))) { @@ -185,8 +181,7 @@ const renderStateName = (key: string, componentName: string) => { {n} {getOrdinalSuffix(n)} hook{' '} - called in{' '} - {componentName} + called in {componentName} ); @@ -194,8 +189,8 @@ const renderStateName = (key: string, componentName: string) => { const WhatsChangedHeader = memo<{ refSticky?: - | ReturnType> - | ((node: HTMLElement | null) => void); + | ReturnType> + | ((node: HTMLElement | null) => void); isSticky?: boolean; calculateStickyTop: (removeSticky?: boolean) => void; isExpanded: boolean; @@ -254,7 +249,7 @@ const WhatsChangedHeader = memo<{ if (!currentUpdate || currentIndex === 0) { return; - }; + } flash(); @@ -282,7 +277,7 @@ const WhatsChangedHeader = memo<{ }, []); const toggleExpanded = useCallback(() => { - setIsExpanded(state => { + setIsExpanded((state) => { if (isSticky && isExpanded) { return state; } @@ -290,18 +285,23 @@ const WhatsChangedHeader = memo<{ }); }, [setIsExpanded, isExpanded, isSticky]); - const onTransitionStart = useCallback((e: TransitionEvent) => { - if (e.propertyName === 'max-height') { - calculateStickyTop(true); - } - }, [calculateStickyTop]); - - const onTransitionEnd = useCallback((e: TransitionEvent) => { - if (e.propertyName === 'max-height') { - calculateStickyTop(false); - } - }, [calculateStickyTop]); + const onTransitionStart = useCallback( + (e: TransitionEvent) => { + if (e.propertyName === 'max-height') { + calculateStickyTop(true); + } + }, + [calculateStickyTop], + ); + const onTransitionEnd = useCallback( + (e: TransitionEvent) => { + if (e.propertyName === 'max-height') { + calculateStickyTop(false); + } + }, + [calculateStickyTop], + ); return ( -
-
-
+ {changes.map((change) => { + const isEntryExpanded = expandedEntries.has(String(change.name)); + const values = refChangesValues.current.get(change.name); + if (!values) return null; + + return ( +
+ +
+
+
+ {values.prevError || values.currError ? ( + + ) : values.diff.changes.length > 0 ? ( + + ) : ( + + )}
- ); - }) - } +
+ ); + })}
); @@ -625,7 +610,7 @@ const DiffChange = ({ }; title: string; renderName: (name: string) => ReactNode; - change: Change; + change: Change; expandedFns: Set; setExpandedFns: (updater: (prev: Set) => Set) => void; }) => { @@ -646,7 +631,7 @@ const DiffChange = ({ if (title === 'Props') { path = diffChange.path.length > 0 - ? `${renderName(String(change.name))}.${formatPath(diffChange.path)}` + ? `${renderName(String(change.name))}.${formatPath(diffChange.path)}` : undefined; } if (title === 'State' && diffChange.path.length > 0) { @@ -700,7 +685,7 @@ const DiffChange = ({ ) : isFunction ? (
- + {formatFunctionPreview( prevDiffValue as (...args: unknown[]) => unknown, expandedFns.has(`${formatPath(diffChange.path)}-prev`), @@ -709,7 +694,7 @@ const DiffChange = ({ {typeof prevDiffValue === 'function' && ( {({ ClipboardIcon }) => <>{ClipboardIcon}} @@ -788,7 +773,7 @@ const DiffChange = ({ {typeof currDiffValue === 'function' && ( {({ ClipboardIcon }) => <>{ClipboardIcon}} @@ -837,7 +822,7 @@ const ReferenceOnlyChange = ({ }: { prevValue: unknown; currValue: unknown; - entryKey: string | number; + entryKey: string | number; expandedFns: Set; setExpandedFns: (updater: (prev: Set) => Set) => void; }) => { @@ -902,10 +887,10 @@ const CountBadge = ({ isFunction, showWarning, }: { - count: number; - forceFlash: boolean; - isFunction: boolean; - showWarning: boolean; + count: number; + forceFlash: boolean; + isFunction: boolean; + showWarning: boolean; }) => { const refTimer = useRef(); const refIsFirstRender = useRef(true); diff --git a/packages/scan/src/web/views/notifications/data.ts b/packages/scan/src/web/views/notifications/data.ts new file mode 100644 index 00000000..54895b4e --- /dev/null +++ b/packages/scan/src/web/views/notifications/data.ts @@ -0,0 +1,203 @@ +import { createContext } from "preact"; +import { SetStateAction } from "preact/compat"; +import { Dispatch, useContext } from "preact/hooks"; + +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 }>; + }; + /** 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; +}; +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"; + } + 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 = (gropedFiberRender: GroupedFiberRender) => { + return ( + gropedFiberRender.changes.context.length === 0 && + gropedFiberRender.changes.props.length === 0 && + gropedFiberRender.changes.state.length === 0 + ); +}; + +export const getTimeSplit = ( + timing: DroppedFramesTiming | InteractionTiming +) => { + switch (timing.kind) { + case "dropped-frames": { + return { + render: timing.renderTime, + other: timing.otherTime, + }; + } + case "interaction": { + return { + render: timing.renderTime, + other: getTotalTime(timing) + timing.renderTime, + }; + } + } +}; + +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 < 200) return "needs-improvement"; + return "high"; + } + } +}; + +export const getReadableSeverity = ( + severity: "low" | "needs-improvement" | "high" +) => { + switch (severity) { + case "high": { + return "Poor"; + } + case "needs-improvement": { + return "Laggy"; + } + case "low": { + return "Good"; + } + } +}; +export const NOTIFICATIONS_BORDER = "#27272A"; +export const useNotificationsContext = () => + useContext(NotificationStateContext); + +export const NotificationStateContext = createContext<{ + notificationState: NotificationsState; + setNotificationState: Dispatch>; + setRoute: ({ + route, + routeMessage, + }: { + route: NotificationsState["route"]; + routeMessage: NotificationsState["routeMessage"] | null; + }) => void; +}>(null!); diff --git a/packages/scan/src/web/views/notifications/details-routes.tsx b/packages/scan/src/web/views/notifications/details-routes.tsx new file mode 100644 index 00000000..7eeb576b --- /dev/null +++ b/packages/scan/src/web/views/notifications/details-routes.tsx @@ -0,0 +1,200 @@ +import { ReactNode, useEffect, useRef, useState } from 'preact/compat'; +import { playNotificationSound } from '~core/utils'; +import { signalNotificationsOpen, signalSettingsOpen } from '~web/state'; +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'; + +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/src/web/views/notifications/icons.tsx b/packages/scan/src/web/views/notifications/icons.tsx new file mode 100644 index 00000000..5733ca42 --- /dev/null +++ b/packages/scan/src/web/views/notifications/icons.tsx @@ -0,0 +1,292 @@ +import { cn } from '~web/utils/helpers'; + +export const ChevronRight = ({ + size = 24, + className, +}: { + size?: number; + className?: string; +}) => ( + + + +); +export const CopyX = ({ + size = 24, + className, +}: { + size?: number; + className?: string; +}) => ( + + + + + + +); + +const SHOW_DOT = false; +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 && (SHOW_DOT || totalSevere > 0) && ( +
+ {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/src/web/views/notifications/notification-header.tsx b/packages/scan/src/web/views/notifications/notification-header.tsx new file mode 100644 index 00000000..e7134a10 --- /dev/null +++ b/packages/scan/src/web/views/notifications/notification-header.tsx @@ -0,0 +1,118 @@ +import { signalNotificationsOpen, signalSettingsOpen } from '~web/state'; +import { cn } from '~web/utils/helpers'; +import { + NotificationEvent, + getComponentName, + getEventSeverity, + getTotalTime, +} from './data'; +import { CloseIcon } from './icons'; + +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/src/web/views/notifications/notification-tabs.tsx b/packages/scan/src/web/views/notifications/notification-tabs.tsx new file mode 100644 index 00000000..117420fa --- /dev/null +++ b/packages/scan/src/web/views/notifications/notification-tabs.tsx @@ -0,0 +1,125 @@ +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 audioContext = new AudioContext(); + if (!prev.audioNotificationsOptions.enabled) { + playNotificationSound(audioContext); + } + return { + ...prev, + audioNotificationsOptions: prev.audioNotificationsOptions + .enabled + ? { + 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/src/web/views/notifications/notifications.tsx b/packages/scan/src/web/views/notifications/notifications.tsx new file mode 100644 index 00000000..68aaa0cc --- /dev/null +++ b/packages/scan/src/web/views/notifications/notifications.tsx @@ -0,0 +1,476 @@ +import { forwardRef } from 'preact/compat'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { Store } from '~core/index'; +import { useToolbarEventLog } from '~core/notifications/event-tracking'; +import { FiberRenders } from '~core/notifications/performance'; +import { iife, invariantError } from '~core/notifications/performance-utils'; +import { playNotificationSound } from '~core/utils'; +import { signalNotificationsOpen } from '~web/state'; +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'; +import { generateId } from '~core/monitor/utils'; + +const getGroupedFiberRenders = (fiberRenders: FiberRenders) => { + const res = Object.values(fiberRenders).map((render) => ({ + id: 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, + // 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; +export 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; + + 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; + } + }, []); + + 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; + }); + }, + }} + > + + {signalNotificationsOpen.value && + Store.inspectState.value.kind === 'inspect-off' && ( + + )} + + ); +}); +export 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; + const date = new Date(event.timestamp); + + 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/src/web/views/notifications/optimize.tsx b/packages/scan/src/web/views/notifications/optimize.tsx new file mode 100644 index 00000000..b234390e --- /dev/null +++ b/packages/scan/src/web/views/notifications/optimize.tsx @@ -0,0 +1,550 @@ +import { useState } from 'preact/hooks'; +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; +}; + +export 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 && `- 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 && `- 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 of 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 of 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. +`; + +export 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 of 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}`; + +export const generateInteractionExplanationPrompt = ({ + 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; +}) => `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 && `- 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 of 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, + 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, + }); + } + } + } + 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/src/web/views/notifications/other-visualization.tsx b/packages/scan/src/web/views/notifications/other-visualization.tsx new file mode 100644 index 00000000..d8624634 --- /dev/null +++ b/packages/scan/src/web/views/notifications/other-visualization.tsx @@ -0,0 +1,781 @@ +import { ReactNode } from 'preact/compat'; +import { useContext, useEffect, useState } from 'preact/hooks'; +import { getIsProduction } from '~core/index'; +import { iife } from '~core/notifications/performance-utils'; +import { cn } from '~web/utils/helpers'; +import { ToolbarElementContext } from '~web/views/widget'; +import { + InteractionEvent, + NotificationEvent, + getTotalTime, + useNotificationsContext, +} from './data'; +import { getLLMPrompt } from './optimize'; +type BaseTimeDataItem = { + name: string; + time: number; + color: string; + kind: + | 'other-not-javascript' + | 'other-javascript' + | 'render' + | 'other-frame-drop' + | 'total-processing-time'; +}; + +type EventHandlerItem = { + name: string; + time: number; + element: string; + count: number; +}; + +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 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 + 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]); + + useEffect(() => { + if (notificationState.route === 'other-visualization') { + setExpandedItems((prev) => + notificationState.routeMessage?.name + ? [notificationState.routeMessage.name] + : prev, + ); + } + }, [notificationState.route]); + + const timeData = getTimeData(selectedEvent, isProduction); + + 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'; + }; + +export const getTotalProcessingTimeInput = (event: NotificationEvent) => { + return { + kind: 'total-processing', + data: { + time: getTotalTime(event.timing), + }, + } satisfies OverviewInput; +}; + +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/src/web/views/notifications/popover.tsx b/packages/scan/src/web/views/notifications/popover.tsx new file mode 100644 index 00000000..ceee44ef --- /dev/null +++ b/packages/scan/src/web/views/notifications/popover.tsx @@ -0,0 +1,181 @@ +import { + ComponentProps, + ReactNode, + createPortal, + useContext, + useEffect, + useRef, + useState, +} from 'preact/compat'; +import { cn } from '~web/utils/helpers'; +import { ToolbarElementContext } from '~web/views/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); + } + }; + + 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, + }; + }; + + return ( + <> + {portalEl && + elBoundingRect && + popoverState !== 'closed' && + createPortal( +
+ {children} +
, + portalEl, + )} + +
+ {triggerContent} +
+ + ); +}; diff --git a/packages/scan/src/web/views/notifications/render-bar-chart.tsx b/packages/scan/src/web/views/notifications/render-bar-chart.tsx new file mode 100644 index 00000000..220a0cd4 --- /dev/null +++ b/packages/scan/src/web/views/notifications/render-bar-chart.tsx @@ -0,0 +1,417 @@ +import { useRef, useState } from 'preact/hooks'; +import { getBatchedRectMap } from 'src/new-outlines'; +import { HighlightStore, drawHighlights } from '~core/heatmap-overlay'; +import { getIsProduction } from '~core/index'; +import { iife } from '~core/notifications/performance-utils'; +import { cn } from '~web/utils/helpers'; +import { + NotificationEvent, + getTotalTime, + isRenderMemoizable, + useNotificationsContext, +} from './data'; + +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, + }, + }; +}; + +export const RenderBarChart = ({ + selectedEvent, +}: { selectedEvent: NotificationEvent }) => { + const { setNotificationState, setRoute } = useNotificationsContext(); + const totalInteractionTime = getTotalTime(selectedEvent.timing); + const nonRender = totalInteractionTime - selectedEvent.timing.renderTime; + const [isProduction] = useState(getIsProduction()); + const events = selectedEvent.groupedFiberRenders; + const bars: Array< + | { kind: 'other-frame-drop'; totalTime: number } + | { kind: 'other-not-javascript'; totalTime: number } + | { kind: 'other-javascript'; totalTime: number } + | { kind: 'render'; event: (typeof events)[number]; totalTime: number } + > = 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 ( +
{ + fadeOutHighlights(); + }} + className={cn(['flex flex-col h-full w-full gap-y-1'])} + > + {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, index) => ( + + ))} +
+ ); +}; + +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; +}; diff --git a/packages/scan/src/web/views/notifications/render-explanation.tsx b/packages/scan/src/web/views/notifications/render-explanation.tsx new file mode 100644 index 00000000..bf42a9e1 --- /dev/null +++ b/packages/scan/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 'preact/hooks'; +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 of 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/src/web/views/notifications/slowdown-history.tsx b/packages/scan/src/web/views/notifications/slowdown-history.tsx new file mode 100644 index 00000000..f79bcb73 --- /dev/null +++ b/packages/scan/src/web/views/notifications/slowdown-history.tsx @@ -0,0 +1,784 @@ +import { useEffect, useRef, useState } from 'preact/compat'; +import { cn } from '~web/utils/helpers'; +import { + DroppedFramesEvent, + InteractionEvent, + NotificationEvent, + getComponentName, + getEventSeverity, + getTotalTime, + useNotificationsContext, +} from './data'; +import { + ChevronRight, + ClearIcon, + KeyboardIcon, + PointerIcon, + TrendingDownIcon, +} from './icons'; +import { Popover } from './popover'; +import { iife } from '~core/notifications/performance-utils'; +import { toolbarEventStore } from '~core/notifications/event-tracking'; + +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; +}; + +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 CollapsedDroppedFrame = { + kind: 'multiple'; + events: Array; + timestamp: number; +}; + +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: 'multiple', + events: [lastEvent.event, curr], + timestamp: Math.max(lastEvent.event.timestamp, curr.timestamp), + }, + ]; + } + return [ + ...prev, + { + kind: 'single', + event: curr, + timestamp: curr.timestamp, + }, + ]; + } + case 'multiple': { + if (curr.kind === 'dropped-frames') { + const eventsWithoutLast = prev.filter((e) => e !== lastEvent); + return [ + ...eventsWithoutLast, + { + kind: 'multiple', + 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); + + 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 'multiple': { + return ( + + ); + } + } + }), + )} +
+
+ ); +}; + +const IndentedContent = ({ + children, +}: { children: JSX.Element | JSX.Element[] }) => ( +
+
+ {children} +
+); + +const CollapsedKeyboard = ({ + collapsedKeyboardInput, + shouldFlash, +}: { + collapsedKeyboardInput: CollapsedKeyboardInput; + + shouldFlash: (id: string) => boolean; +}) => { + const [expanded, setExpanded] = useState(false); + + const severity = collapsedKeyboardInput.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 = collapsedKeyboardInput.events.reduce( + (prev, curr) => (shouldFlash(curr.id) ? prev + 1 : prev), + 0, + ); + + const newFlash = useNestedFlash({ + flashingItemsCount, + totalEvents: collapsedKeyboardInput.events.length, + }); + + if (expanded) { + return ( +
+ + + {collapsedKeyboardInput.events + .toSorted((a, b) => b.timestamp - a.timestamp) + .map((event) => ( + + ))} + +
+ ); + } + + return ( + + ); +}; + +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); + + 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; +}; + +const CollapsedItem = ({ + historyItem, + shouldFlash, +}: { + historyItem: CollapsedDroppedFrame; + shouldFlash: (id: string) => boolean; +}) => { + useNotificationsContext(); + const [expanded, setExpanded] = useState(false); + + const severity = historyItem.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 = historyItem.events.reduce( + (prev, curr) => (shouldFlash(curr.id) ? prev + 1 : prev), + 0, + ); + + const newFlash = useNestedFlash({ + flashingItemsCount, + totalEvents: historyItem.events.length, + }); + + if (expanded) { + return ( +
+ + + {historyItem.events + .toSorted((a, b) => b.timestamp - a.timestamp) + .map((event) => ( + + ))} + +
+ ); + } + + return ( + + ); +}; diff --git a/packages/scan/src/web/views/settings-menu.tsx b/packages/scan/src/web/views/settings-menu.tsx new file mode 100644 index 00000000..236dd669 --- /dev/null +++ b/packages/scan/src/web/views/settings-menu.tsx @@ -0,0 +1,165 @@ +import { cn } from '~web/utils/helpers'; + +type SettingsMenuProps = { + /** Current outline state: 'off', 'always-on', or 'smart' */ + outlineState: 'off' | 'always-on' | 'smart'; + /** Callback when outline state changes */ + onOutlineStateChange: (state: 'off' | 'always-on' | 'smart') => void; + /** Whether the FPS counter is visible */ + isFpsVisible: boolean; + /** Callback when FPS visibility changes */ + onFpsVisibilityChange: (visible: boolean) => void; + /** Whether notifications are visible */ + areNotificationsVisible: boolean; + /** Callback when notifications visibility changes */ + onNotificationsVisibilityChange: (visible: boolean) => void; + /** Callback to open summary view */ + onOpenSummary: () => void; + /** Callback to close settings menu */ + onClose: () => void; +}; + +export function SettingsMenu({ + outlineState, + onOutlineStateChange, + isFpsVisible, + onFpsVisibilityChange, + areNotificationsVisible, + onNotificationsVisibilityChange, + onOpenSummary, + onClose, +}: SettingsMenuProps) { + return ( +
+ {/* Minimal Header */} +
+ +
+ + {/* Content */} +
+
+ {/* Left Column */} +
+ {/* Quick Actions */} +
+

+ Quick Actions +

+ +
+ + {/* View Options */} +
+

+ View Options +

+
+ + +
+
+
+ + {/* Right Column */} +
+
+

+ Outline Mode +

+
+ + + +
+
+
+
+
+
+ ); +} diff --git a/packages/scan/src/web/components/slider/index.tsx b/packages/scan/src/web/views/slider/index.tsx similarity index 100% rename from packages/scan/src/web/components/slider/index.tsx rename to packages/scan/src/web/views/slider/index.tsx diff --git a/packages/scan/src/web/components/sticky-section/index.tsx b/packages/scan/src/web/views/sticky-section/index.tsx similarity index 100% rename from packages/scan/src/web/components/sticky-section/index.tsx rename to packages/scan/src/web/views/sticky-section/index.tsx diff --git a/packages/scan/src/web/components/toggle/index.tsx b/packages/scan/src/web/views/toggle/index.tsx similarity index 100% rename from packages/scan/src/web/components/toggle/index.tsx rename to packages/scan/src/web/views/toggle/index.tsx diff --git a/packages/scan/src/web/components/widget/components-tree/breadcrumb.tsx b/packages/scan/src/web/views/widget/components-tree/breadcrumb.tsx similarity index 95% rename from packages/scan/src/web/components/widget/components-tree/breadcrumb.tsx rename to packages/scan/src/web/views/widget/components-tree/breadcrumb.tsx index fe54acda..f3162c99 100644 --- a/packages/scan/src/web/components/widget/components-tree/breadcrumb.tsx +++ b/packages/scan/src/web/views/widget/components-tree/breadcrumb.tsx @@ -1,14 +1,16 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import { Store } from '~core/index'; -import { Icon } from '~web/components/icon'; +import { cn } from '~web/utils/helpers'; +import { Icon } from '~web/views/icon'; import { getCompositeFiberFromElement, getInspectableAncestors, -} from '~web/components/inspector/utils'; -import { cn } from '~web/utils/helpers'; +} from '~web/views/inspector/utils'; import { type TreeItem, signalSkipTreeUpdate } from './state'; -export const Breadcrumb = ({ selectedElement }: { selectedElement: HTMLElement | null }) => { +export const Breadcrumb = ({ + selectedElement, +}: { selectedElement: HTMLElement | null }) => { const refContainer = useRef(null); const refPaths = useRef(null); diff --git a/packages/scan/src/web/components/widget/components-tree/index.tsx b/packages/scan/src/web/views/widget/components-tree/index.tsx similarity index 91% rename from packages/scan/src/web/components/widget/components-tree/index.tsx rename to packages/scan/src/web/views/widget/components-tree/index.tsx index e40fc0b1..f77ffe11 100644 --- a/packages/scan/src/web/components/widget/components-tree/index.tsx +++ b/packages/scan/src/web/views/widget/components-tree/index.tsx @@ -6,12 +6,6 @@ import { useState, } from 'preact/hooks'; import { Store } from '~core/index'; -import { Icon } from '~web/components/icon'; -import { inspectorUpdateSignal } from '~web/components/inspector/states'; -import { - type InspectableElement, - getInspectableElements, -} from '~web/components/inspector/utils'; import { LOCALSTORAGE_KEY, MIN_CONTAINER_WIDTH, @@ -25,6 +19,12 @@ import { saveLocalStorage, } from '~web/utils/helpers'; import { getFiberPath } from '~web/utils/pin'; +import { Icon } from '~web/views/icon'; +import { inspectorUpdateSignal } from '~web/views/inspector/states'; +import { + type InspectableElement, + getInspectableElements, +} from '~web/views/inspector/utils'; import { getCompositeComponentFromElement } from '../../inspector/utils'; import { Breadcrumb } from './breadcrumb'; import { @@ -78,7 +78,10 @@ const calculateIndentSize = (containerWidth: number, maxDepth: number) => { if (availableSpace < MIN_TOTAL_INDENT) return MIN_INDENT; // Otherwise, calculate based on available space - const targetTotalIndent = Math.min(availableSpace * 0.3, maxDepth * MAX_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)); @@ -125,7 +128,10 @@ const isValidTypeSearch = (typeSearches: string[]) => { return true; }; -const matchesTypeSearch = (typeSearches: string[], wrapperTypes: Array<{ type: string }>) => { +const matchesTypeSearch = ( + typeSearches: string[], + wrapperTypes: Array<{ type: string }>, +) => { if (typeSearches.length === 0) return true; if (!wrapperTypes.length) return false; @@ -142,7 +148,10 @@ const matchesTypeSearch = (typeSearches: string[], wrapperTypes: Array<{ type: s return true; }; -const useNodeHighlighting = (node: FlattenedNode, searchValue: typeof searchState.value) => { +const useNodeHighlighting = ( + node: FlattenedNode, + searchValue: typeof searchState.value, +) => { return useMemo(() => { const { query, matches } = searchValue; const isMatch = matches.some((match) => match.nodeId === node.nodeId); @@ -152,7 +161,7 @@ const useNodeHighlighting = (node: FlattenedNode, searchValue: typeof searchStat if (!query || !isMatch) { return { highlightedText: {node.label}, - typeHighlight: false + typeHighlight: false, }; } @@ -217,7 +226,7 @@ const useNodeHighlighting = (node: FlattenedNode, searchValue: typeof searchStat return { highlightedText: textContent, - typeHighlight: matchesType && typeSearches.length > 0 + typeHighlight: matchesType && typeSearches.length > 0, }; }, [node.label, node.nodeId, node.fiber, searchValue]); }; @@ -244,7 +253,10 @@ const TreeNodeItem = ({ } }, [hasChildren, node.nodeId, onToggle]); - const { highlightedText, typeHighlight } = useNodeHighlighting(node, searchValue); + const { highlightedText, typeHighlight } = useNodeHighlighting( + node, + searchValue, + ); const componentTypes = useMemo(() => { if (!node.fiber) return null; @@ -821,10 +833,7 @@ export const ComponentsTree = () => { return ( <> -
+
@@ -914,65 +923,63 @@ export const ComponentsTree = () => { placeholder="Component name, /regex/, or [type]" />
- { - searchState.value.query - ? ( + {searchState.value.query ? ( + <> + + {searchState.value.currentMatchIndex + 1} + {'|'} + {searchState.value.matches.length} + + {!!searchState.value.matches.length && ( <> - - {searchState.value.currentMatchIndex + 1} - {'|'} - {searchState.value.matches.length} - - {!!searchState.value.matches.length && ( - <> - - - - )} + - ) - : !!flattenedNodes.length && ( - - {flattenedNodes.length} - - ) - } + )} + + + ) : ( + !!flattenedNodes.length && ( + + {flattenedNodes.length} + + ) + )}
diff --git a/packages/scan/src/web/components/widget/components-tree/state.ts b/packages/scan/src/web/views/widget/components-tree/state.ts similarity index 100% rename from packages/scan/src/web/components/widget/components-tree/state.ts rename to packages/scan/src/web/views/widget/components-tree/state.ts diff --git a/packages/scan/src/web/views/widget/debug-runtime.tsx b/packages/scan/src/web/views/widget/debug-runtime.tsx new file mode 100644 index 00000000..e9b2aadf --- /dev/null +++ b/packages/scan/src/web/views/widget/debug-runtime.tsx @@ -0,0 +1,214 @@ +import { useSyncExternalStore } from 'preact/compat'; +import { useMemo, useState } from 'preact/hooks'; +import { FiberRenders } from '~core/monitor/performance'; +import { + debugEventStore, + toolbarEventStore, + useToolbarEventLog, +} from '~core/precise-activation'; + +export const DebugRunTime = () => { + const { + state: { events: debugEvents }, + } = useSyncExternalStore(debugEventStore.subscribe, debugEventStore.getState); + const { state } = useToolbarEventLog(); + const [filters, setFilters] = useState({ + showLongRender: true, + showLongInteraction: true, + minLatency: 0, + searchQuery: '', + timeRange: { + start: 0, + end: Infinity, + }, + }); + + const getRenderTime = (fiberRenders: FiberRenders) => { + return Object.values(fiberRenders).reduce( + (prev, curr) => prev + curr.selfTime, + 0, + ); + }; + + const filteredEvents = useMemo(() => { + return state.events.filter((event) => { + const isLongRender = event.kind === 'long-render'; + if (!filters.showLongRender && isLongRender) return false; + if (!filters.showLongInteraction && !isLongRender) return false; + if (event.data.meta.latency < filters.minLatency) return false; + if ( + event.data.startAt < filters.timeRange.start || + event.data.endAt > filters.timeRange.end + ) + return false; + + if (filters.searchQuery) { + const searchLower = filters.searchQuery.toLowerCase(); + const fiberRenders = isLongRender + ? event.data.meta.fiberRenders + : event.data.meta.detailedTiming.fiberRenders; + const fiberKeys = Object.keys(fiberRenders).join(' ').toLowerCase(); + if (!fiberKeys.includes(searchLower)) return false; + } + + return true; + }); + }, [state.events, filters]); + + return ( +
+
+
+
+ + + +
+
+ + setFilters((f) => ({ + ...f, + searchQuery: e.currentTarget.value, + })) + } + /> + +
+
+ +
+ {filteredEvents.toReversed().map((event, index, reversedArray) => { + const isLongRender = event.kind === 'long-render'; + const latency = event.data.meta.latency; + const fiberRenders = isLongRender + ? event.data.meta.fiberRenders + : event.data.meta.detailedTiming.fiberRenders; + const fiberCount = Object.keys(fiberRenders).length; + const totalRenderTime = getRenderTime(fiberRenders).toFixed(1); + const prevEvent = reversedArray[index + 1]; + + return ( +
+
+
+ + {isLongRender ? '🔴' : '⚠️'} + + {latency.toFixed(1)}ms + ({fiberCount} fibers) + + {totalRenderTime}ms render + + + {(event.data.endAt - event.data.startAt).toFixed(1)}ms + total + +
+
+ {event.data.startAt} + + {event.data.endAt} + {prevEvent && ( + + + + {(prevEvent.data.startAt - event.data.endAt).toFixed(1)} + ms + + )} +
+
+
+ ); + })} +
+
+ +
+
+
Debug Events
+ +
+
+ {debugEvents.toReversed().map((event, index) => ( +
+
+
+ + {event.kind === 'start-interaction' ? '▶️' : '⏹️'} + + {event.at.toFixed(1)}ms + {event.kind} +
+ {JSON.stringify(event.meta)} +
+
+ ))} +
+
+
+ ); +}; diff --git a/packages/scan/src/web/views/widget/fps-meter.tsx b/packages/scan/src/web/views/widget/fps-meter.tsx new file mode 100644 index 00000000..86920ef0 --- /dev/null +++ b/packages/scan/src/web/views/widget/fps-meter.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { getFPS } from '~core/instrumentation'; +import { cn } from '~web/utils/helpers'; + +export const FpsMeter = ({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 + +
+ ); +}; + diff --git a/packages/scan/src/web/components/widget/header.tsx b/packages/scan/src/web/views/widget/header.tsx similarity index 56% rename from packages/scan/src/web/components/widget/header.tsx rename to packages/scan/src/web/views/widget/header.tsx index 8f7d1081..18d5abcb 100644 --- a/packages/scan/src/web/components/widget/header.tsx +++ b/packages/scan/src/web/views/widget/header.tsx @@ -7,17 +7,7 @@ import { Icon } from '../icon'; import { timelineState } from '../inspector/states'; import { getOverrideMethods } from '../inspector/utils'; -// const REPLAY_DELAY_MS = 300; - export const BtnReplay = () => { - // const refTimeout = useRef(); - // const replayState = useRef({ - // isReplaying: false, - // toggleDisabled: (disabled: boolean, button: HTMLElement) => { - // button.classList[disabled ? 'add' : 'remove']('disabled'); - // }, - // }); - const [canEdit, setCanEdit] = useState(false); const isSettingsOpen = signalIsSettingsOpen.value; @@ -30,46 +20,12 @@ export const BtnReplay = () => { }); }, []); - // const handleReplay = (e: MouseEvent) => { - // e.stopPropagation(); - // const { overrideProps, overrideHookState } = getOverrideMethods(); - // const state = replayState.current; - // const button = e.currentTarget as HTMLElement; - - // const inspectState = Store.inspectState.value; - // if (state.isReplaying || inspectState.kind !== 'focused') return; - - // const { parentCompositeFiber } = getCompositeComponentFromElement( - // inspectState.focusedDomElement, - // ); - // if (!parentCompositeFiber || !overrideProps || !overrideHookState) return; - - // state.isReplaying = true; - // state.toggleDisabled(true, button); - - // void replayComponent(parentCompositeFiber) - // .catch(() => void 0) - // .finally(() => { - // clearTimeout(refTimeout.current); - // if (document.hidden) { - // state.isReplaying = false; - // state.toggleDisabled(false, button); - // } else { - // refTimeout.current = setTimeout(() => { - // state.isReplaying = false; - // state.toggleDisabled(false, button); - // }, REPLAY_DELAY_MS); - // } - // }); - // }; - if (!canEdit) return null; return ( ); }; -// const useSubscribeFocusedFiber = (onUpdate: () => void) => { -// // biome-ignore lint/correctness/useExhaustiveDependencies: no deps -// useEffect(() => { -// const subscribe = () => { -// if (Store.inspectState.value.kind !== 'focused') { -// return; -// } -// onUpdate(); -// }; - -// const unSubReportTime = Store.lastReportTime.subscribe(subscribe); -// const unSubState = Store.inspectState.subscribe(subscribe); -// return () => { -// unSubReportTime(); -// unSubState(); -// }; -// }, []); -// }; const HeaderInspect = () => { const refReRenders = useRef(null); @@ -126,8 +64,8 @@ const HeaderInspect = () => { const reRenders = Math.max(0, totalUpdates - 1); const headerText = isVisible - ? `#${windowOffset + currentIndex} Re-render` - : `${reRenders} Re-renders`; + ? `#${windowOffset + currentIndex} Re-render since selected` + : `${reRenders === 0 ? 'No renders since selected' : `${reRenders} Re-renders since selected`}`; let formattedTime: string | undefined; if (reRenders > 0 && currentIndex >= 0 && currentIndex < updates.length) { @@ -151,57 +89,44 @@ const HeaderInspect = () => { const componentName = useMemo(() => { if (!currentFiber) return null; - const { name, wrappers, wrapperTypes } = getExtendedDisplayName(currentFiber); + const { name, wrappers, wrapperTypes } = + getExtendedDisplayName(currentFiber); const title = wrappers.length ? `${wrappers.join('(')}(${name})${')'.repeat(wrappers.length)}` - : name ?? ''; + : (name ?? ''); const firstWrapperType = wrapperTypes[0]; return ( - + {name ?? 'Unknown'} - { - !!firstWrapperType && ( - <> - - {firstWrapperType.type} - - {firstWrapperType.compiler && ( - - )} - - ) - } + {!!firstWrapperType && ( + <> + + {firstWrapperType.type} + + {firstWrapperType.compiler && ( + + )} + + )} - { - wrapperTypes.length > 1 && ( - - ×{wrapperTypes.length - 1} - - ) - } - - {' • '} - + {wrapperTypes.length > 1 && ( + + ×{wrapperTypes.length - 1} + + )} ); }, [currentFiber]); @@ -224,7 +149,6 @@ const HeaderInspect = () => { className="with-data-text cursor-pointer !overflow-visible" title="Click to toggle between rerenders and total renders" /> -
); @@ -267,8 +191,6 @@ export const Header = () => {
- {/* */} - {/* {Store.inspectState.value.kind !== 'inspect-off' && } */} +
+
+ +
+ +
+
+ +
+
+ + {ReactScanInternals.options.value.showFPS && } +
+ + ); +}; + +const FPSWrapper = () => { + 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/src/web/components/widget/types.ts b/packages/scan/src/web/views/widget/types.ts similarity index 100% rename from packages/scan/src/web/components/widget/types.ts rename to packages/scan/src/web/views/widget/types.ts diff --git a/packages/scan/tsup.config.ts b/packages/scan/tsup.config.ts index d13b7eb4..af65d695 100644 --- a/packages/scan/tsup.config.ts +++ b/packages/scan/tsup.config.ts @@ -1,27 +1,27 @@ -import * as fs from 'node:fs'; -import fsPromise from 'node:fs/promises'; -import path from 'node:path'; -import { TsconfigPathsPlugin } from '@esbuild-plugins/tsconfig-paths'; -import { init, parse } from 'es-module-lexer'; -import { defineConfig } from 'tsup'; -import { workerPlugin } from './worker-plugin'; +import * as fs from "node:fs"; +import fsPromise from "node:fs/promises"; +import path from "node:path"; +import { TsconfigPathsPlugin } from "@esbuild-plugins/tsconfig-paths"; +import { init, parse } from "es-module-lexer"; +import { defineConfig } from "tsup"; +import { workerPlugin } from "./worker-plugin"; -const DIST_PATH = './dist'; +const DIST_PATH = "./dist"; const addDirectivesToChunkFiles = async (readPath: string): Promise => { try { const files = await fsPromise.readdir(readPath); for (const file of files) { - if (file.endsWith('.mjs') || file.endsWith('.js')) { + if (file.endsWith(".mjs") || file.endsWith(".js")) { const filePath = path.join(readPath, file); - const data = await fsPromise.readFile(filePath, 'utf8'); + const data = await fsPromise.readFile(filePath, "utf8"); const updatedContent = `'use client';\n${data}`; - await fsPromise.writeFile(filePath, updatedContent, 'utf8'); + await fsPromise.writeFile(filePath, updatedContent, "utf8"); } } } catch (err) { // biome-ignore lint/suspicious/noConsole: Intended debug output - console.error('Error:', err); + console.error("Error:", err); } }; @@ -52,7 +52,7 @@ void (async () => { } fs.mkdirSync(DIST_PATH, { recursive: true }); - const code = fs.readFileSync('./src/core/index.ts', 'utf8'); + const code = fs.readFileSync("./src/core/index.ts", "utf8"); const [_, allExports] = parse(code); const names: Array = []; for (const exportItem of allExports) { @@ -63,7 +63,7 @@ void (async () => { `export let ${name}=()=>{console.error('Do not use ${name} directly in a Server Component module. It should only be used in a Client Component.');return undefined}`; const createVar = (name: string) => `export let ${name}=undefined`; - let script = ''; + let script = ""; for (const name of names) { if (name[0].toLowerCase() === name[0]) { script += `${createFn(name)}\n`; @@ -73,7 +73,7 @@ void (async () => { } setTimeout(() => { - for (const ext of ['js', 'mjs', 'global.js']) { + for (const ext of ["js", "mjs", "global.js"]) { fs.writeFileSync(`./dist/rsc-shim.${ext}`, script); } }, 500); @@ -81,7 +81,7 @@ void (async () => { export default defineConfig([ { - entry: ['./src/auto.ts', './src/install-hook.ts'], + entry: ["./src/auto.ts", "./src/install-hook.ts"], outDir: DIST_PATH, banner: { js: banner, @@ -89,40 +89,42 @@ export default defineConfig([ splitting: false, clean: false, sourcemap: false, - format: ['iife'], - target: 'esnext', - platform: 'browser', + format: ["iife"], + target: "esnext", + platform: "browser", treeshake: true, dts: true, - minify: process.env.NODE_ENV === 'production' ? 'terser' : false, + minify: process.env.NODE_ENV === "production" ? "terser" : false, + // minify: true, + // minify: false, env: { - NODE_ENV: process.env.NODE_ENV ?? 'development', + NODE_ENV: process.env.NODE_ENV ?? "development", }, external: [ - 'react', - 'react-dom', - 'next', - 'next/navigation', - 'react-router', - 'react-router-dom', - '@remix-run/react', + "react", + "react-dom", + "next", + "next/navigation", + "react-router", + "react-router-dom", + "@remix-run/react", ], esbuildPlugins: [workerPlugin], loader: { - '.css': 'text', - '.worker.js': 'text', + ".css": "text", + ".worker.js": "text", }, }, { entry: [ - './src/index.ts', - './src/install-hook.ts', - './src/core/monitor/index.ts', - './src/core/monitor/params/next.ts', - './src/core/monitor/params/react-router-v5.ts', - './src/core/monitor/params/react-router-v6.ts', - './src/core/monitor/params/remix.ts', - './src/core/monitor/params/astro/component.ts', + "./src/index.ts", + "./src/install-hook.ts", + "./src/core/monitor/index.ts", + "./src/core/monitor/params/next.ts", + "./src/core/monitor/params/react-router-v5.ts", + "./src/core/monitor/params/react-router-v6.ts", + "./src/core/monitor/params/remix.ts", + "./src/core/monitor/params/astro/component.ts", ], banner: { js: banner, @@ -131,14 +133,14 @@ export default defineConfig([ splitting: false, clean: false, sourcemap: false, - format: ['cjs', 'esm'], - target: 'esnext', - platform: 'browser', + format: ["cjs", "esm"], + target: "esnext", + platform: "browser", // FIXME: tree shaking removes use client directive // Info: vercel analytics does the same thing- /~https://github.com/vercel/analytics/blob/main/packages/web/tsup.config.js treeshake: false, dts: true, - watch: process.env.NODE_ENV === 'development', + watch: process.env.NODE_ENV === "development", async onSuccess() { await Promise.all([ addDirectivesToChunkFiles(DIST_PATH), @@ -148,37 +150,34 @@ export default defineConfig([ }, minify: false, env: { - NODE_ENV: process.env.NODE_ENV ?? 'development', + NODE_ENV: process.env.NODE_ENV ?? "development", NPM_PACKAGE_VERSION: JSON.parse( - fs.readFileSync( - path.join(__dirname, '../scan', 'package.json'), - 'utf8', - ), + fs.readFileSync(path.join(__dirname, "../scan", "package.json"), "utf8") ).version, }, external: [ - 'react', - 'react-dom', - 'next', - 'next/navigation', - 'react-router', - 'react-router-dom', - '@remix-run/react', - 'preact', - '@preact/signals', + "react", + "react-dom", + "next", + "next/navigation", + "react-router", + "react-router-dom", + "@remix-run/react", + "preact", + "@preact/signals", ], loader: { - '.css': 'text', + ".css": "text", }, esbuildPlugins: [ workerPlugin, TsconfigPathsPlugin({ - tsconfig: path.resolve(__dirname, './tsconfig.json'), + tsconfig: path.resolve(__dirname, "./tsconfig.json"), }), ], }, { - entry: ['./src/cli.mts'], + entry: ["./src/cli.mts"], outDir: DIST_PATH, banner: { js: banner, @@ -186,60 +185,60 @@ export default defineConfig([ splitting: false, clean: false, sourcemap: false, - format: ['cjs'], - target: 'esnext', - platform: 'node', + format: ["cjs"], + target: "esnext", + platform: "node", minify: false, env: { - NODE_ENV: process.env.NODE_ENV ?? 'development', + NODE_ENV: process.env.NODE_ENV ?? "development", }, - watch: process.env.NODE_ENV === 'development', + watch: process.env.NODE_ENV === "development", }, { entry: [ - './src/react-component-name/index.ts', - './src/react-component-name/vite.ts', - './src/react-component-name/webpack.ts', - './src/react-component-name/esbuild.ts', - './src/react-component-name/rspack.ts', - './src/react-component-name/rolldown.ts', - './src/react-component-name/rollup.ts', - './src/react-component-name/astro.ts', + "./src/react-component-name/index.ts", + "./src/react-component-name/vite.ts", + "./src/react-component-name/webpack.ts", + "./src/react-component-name/esbuild.ts", + "./src/react-component-name/rspack.ts", + "./src/react-component-name/rolldown.ts", + "./src/react-component-name/rollup.ts", + "./src/react-component-name/astro.ts", ], outDir: `${DIST_PATH}/react-component-name`, splitting: false, sourcemap: false, clean: false, - format: ['cjs', 'esm'], - target: 'esnext', + format: ["cjs", "esm"], + target: "esnext", external: [ - 'unplugin', - 'estree-walker', - '@rollup/pluginutils', - '@babel/types', - '@babel/parser', - '@babel/traverse', - '@babel/generator', - '@babel/core', - 'rollup', - 'webpack', - 'esbuild', - 'rspack', - 'vite', + "unplugin", + "estree-walker", + "@rollup/pluginutils", + "@babel/types", + "@babel/parser", + "@babel/traverse", + "@babel/generator", + "@babel/core", + "rollup", + "webpack", + "esbuild", + "rspack", + "vite", ], dts: true, minify: false, treeshake: true, env: { - NODE_ENV: process.env.NODE_ENV || 'development', + NODE_ENV: process.env.NODE_ENV || "development", }, outExtension: ({ format }) => ({ - js: format === 'esm' ? '.mjs' : '.js', + js: format === "esm" ? ".mjs" : ".js", }), esbuildOptions: (options, context) => { - options.mainFields = ['module', 'main']; - options.conditions = ['import', 'require', 'node', 'default']; - options.format = context.format === 'esm' ? 'esm' : 'cjs'; + options.mainFields = ["module", "main"]; + options.conditions = ["import", "require", "node", "default"]; + options.format = context.format === "esm" ? "esm" : "cjs"; options.preserveSymlinks = true; }, }, diff --git a/packages/website/app/layout.tsx b/packages/website/app/layout.tsx index 89d048cb..6c44faa8 100644 --- a/packages/website/app/layout.tsx +++ b/packages/website/app/layout.tsx @@ -3,8 +3,8 @@ import localFont from 'next/font/local'; import Script from 'next/script'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import Header from '../components/header'; -import Footer from '../components/footer'; +import Header from '../views/header'; +import Footer from '../views/footer'; const geistSans = localFont({ src: './fonts/GeistVF.woff', diff --git a/packages/website/components/installl-guide.tsx b/packages/website/components/installl-guide.tsx index e9694108..6c7c8e73 100644 --- a/packages/website/components/installl-guide.tsx +++ b/packages/website/components/installl-guide.tsx @@ -183,7 +183,7 @@ export default function App() { key={tab} onClick={() => handleTabChange(tab)} className={`relative px-4 py-2 text-[15px] transition-colors ${activeTab === tab - ? 'bg-[#1e1e1e] text-white before:absolute before:left-0 before:top-0 before:h-[1px] before:w-full before:bg-[#7a68e7]' + ? 'bg-[#1e1e1e] text-white before:absolute before:left-0 before:top-0 before:h-[1px] before:w-full before:bg-[#A284F5]' : 'text-[#969696] hover:text-white' }`} > diff --git a/packages/website/public/logo.svg b/packages/website/public/logo.svg index 5030b05a..bc119cb6 100644 --- a/packages/website/public/logo.svg +++ b/packages/website/public/logo.svg @@ -1,13 +1,13 @@ - + - - + + - - - - + + + + diff --git a/packages/website/tailwind.config.ts b/packages/website/tailwind.config.ts index e5aca39f..869d741c 100644 --- a/packages/website/tailwind.config.ts +++ b/packages/website/tailwind.config.ts @@ -1,27 +1,27 @@ -import type { Config } from 'tailwindcss'; +import type { Config } from "tailwindcss"; const config: Config = { content: [ - './pages/**/*.{js,ts,jsx,tsx,mdx}', - './components/**/*.{js,ts,jsx,tsx,mdx}', - './app/**/*.{js,ts,jsx,tsx,mdx}', + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./views/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", ], plugins: [], theme: { extend: { colors: { scan: { - 50: 'oklch(96.51% 0.015 290.31 / )', - 100: 'oklch(91.77% 0.035 292.75 / )', - 200: 'oklch(83.48% 0.073 291.8 / )', - 300: 'oklch(75.37% 0.11 290.05 / )', - 400: 'oklch(68.25% 0.145 288.86 / )', - 500: 'oklch(59.88% 0.185 285.85 / )', - 600: 'oklch(50.07% 0.181 284.57 / )', - 700: 'oklch(41.78% 0.117 287.1 / )', - 800: 'oklch(34.52% 0.047 290.52 / )', - 900: 'oklch(24.54% 0.004 308.28 / )', - 950: 'oklch(18.22% 0 NaN / )', + 50: "oklch(96.51% 0.015 290.31 / )", + 100: "oklch(91.77% 0.035 292.75 / )", + 200: "oklch(83.48% 0.073 291.8 / )", + 300: "oklch(75.37% 0.11 290.05 / )", + 400: "oklch(68.25% 0.145 288.86 / )", + 500: "oklch(59.88% 0.185 285.85 / )", + 600: "oklch(50.07% 0.181 284.57 / )", + 700: "oklch(41.78% 0.117 287.1 / )", + 800: "oklch(34.52% 0.047 290.52 / )", + 900: "oklch(24.54% 0.004 308.28 / )", + 950: "oklch(18.22% 0 NaN / )", }, }, },