diff --git a/packages/scan/src/web/constants.ts b/packages/scan/src/web/constants.ts index 3b7b8bcc..430568b6 100644 --- a/packages/scan/src/web/constants.ts +++ b/packages/scan/src/web/constants.ts @@ -10,3 +10,5 @@ export const MIN_CONTAINER_WIDTH = 240; export const LOCALSTORAGE_KEY = "react-scan-widget-settings-v2"; export const LOCALSTORAGE_COLLAPSED_KEY = "react-scan-widget-collapsed-v1"; export const LOCALSTORAGE_LAST_VIEW_KEY = "react-scan-widget-last-view-v1"; + +export const RANKED_BAR_CHART_WIDTH_DENOMINATOR_MIN = 1e-6; diff --git a/packages/scan/src/web/utils/ranked-bar-chart-search.test.ts b/packages/scan/src/web/utils/ranked-bar-chart-search.test.ts new file mode 100644 index 00000000..79d90ac3 --- /dev/null +++ b/packages/scan/src/web/utils/ranked-bar-chart-search.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { RANKED_BAR_CHART_WIDTH_DENOMINATOR_MIN } from '~web/constants'; +import { + filterSortedRankedBarsBySearch, + getRankedBarDisplayLabel, + getSafeRankedWidthDenominator, + rankedBarMatchesSearch, + type RankedBarChartBar, +} from '~web/utils/ranked-bar-chart-search'; + +const renderBar = (name: string, totalTime: number): RankedBarChartBar => ({ + kind: 'render', + event: { name }, + totalTime, +}); + +const otherJavascriptBar = (totalTime: number): RankedBarChartBar => ({ + kind: 'other-javascript', + totalTime, +}); + +describe('getRankedBarDisplayLabel', () => { + it('returns component name for render bars', () => { + expect(getRankedBarDisplayLabel(renderBar('TodoList', 10))).toBe('TodoList'); + }); + + it('returns fixed labels for non-render bars', () => { + expect(getRankedBarDisplayLabel(otherJavascriptBar(5))).toBe( + 'JavaScript/React Hooks', + ); + expect( + getRankedBarDisplayLabel({ kind: 'other-frame-drop', totalTime: 1 }), + ).toBe('JavaScript, DOM updates, Draw Frame'); + expect( + getRankedBarDisplayLabel({ kind: 'other-not-javascript', totalTime: 2 }), + ).toBe('Update DOM and Draw New Frame'); + }); +}); + +describe('rankedBarMatchesSearch', () => { + it('matches every bar when query is empty', () => { + expect(rankedBarMatchesSearch(renderBar('Any', 1), '')).toBe(true); + }); + + it('matches case-insensitive substrings on component names', () => { + expect(rankedBarMatchesSearch(renderBar('TodoListForm', 1), 'todo')).toBe( + true, + ); + expect(rankedBarMatchesSearch(renderBar('TodoListForm', 1), 'FORM')).toBe( + true, + ); + expect(rankedBarMatchesSearch(renderBar('TodoListForm', 1), 'missing')).toBe( + false, + ); + }); + + it('matches non-render bar labels', () => { + expect(rankedBarMatchesSearch(otherJavascriptBar(1), 'hooks')).toBe(true); + expect(rankedBarMatchesSearch(otherJavascriptBar(1), 'java')).toBe(true); + }); +}); + +describe('filterSortedRankedBarsBySearch', () => { + const sortedBars: RankedBarChartBar[] = [ + renderBar('TodoListForm', 24), + renderBar('TodoListItem', 6), + otherJavascriptBar(10), + ]; + + it('returns all bars for empty or whitespace query', () => { + expect(filterSortedRankedBarsBySearch(sortedBars, '')).toEqual(sortedBars); + expect(filterSortedRankedBarsBySearch(sortedBars, ' ')).toEqual(sortedBars); + }); + + it('returns only matching bars in original order', () => { + expect(filterSortedRankedBarsBySearch(sortedBars, 'TodoList')).toEqual([ + renderBar('TodoListForm', 24), + renderBar('TodoListItem', 6), + ]); + }); + + it('returns empty array when nothing matches', () => { + expect(filterSortedRankedBarsBySearch(sortedBars, 'xyz')).toEqual([]); + }); +}); + +describe('getSafeRankedWidthDenominator', () => { + it('returns the value when above the floor', () => { + expect(getSafeRankedWidthDenominator(240)).toBe(240); + }); + + it('returns the minimum constant when sum is zero', () => { + expect(getSafeRankedWidthDenominator(0)).toBe( + RANKED_BAR_CHART_WIDTH_DENOMINATOR_MIN, + ); + }); +}); diff --git a/packages/scan/src/web/utils/ranked-bar-chart-search.ts b/packages/scan/src/web/utils/ranked-bar-chart-search.ts new file mode 100644 index 00000000..e1080072 --- /dev/null +++ b/packages/scan/src/web/utils/ranked-bar-chart-search.ts @@ -0,0 +1,54 @@ +import { RANKED_BAR_CHART_WIDTH_DENOMINATOR_MIN } from '~web/constants'; + +interface RankedBarChartRenderEvent { + name: string; +} + +export type RankedBarChartBar = + | { kind: 'other-frame-drop'; totalTime: number } + | { kind: 'other-not-javascript'; totalTime: number } + | { kind: 'other-javascript'; totalTime: number } + | { kind: 'render'; event: RankedBarChartRenderEvent; totalTime: number }; + +export const getRankedBarDisplayLabel = (bar: RankedBarChartBar): string => { + switch (bar.kind) { + case 'other-frame-drop': { + return 'JavaScript, DOM updates, Draw Frame'; + } + case 'other-javascript': { + return 'JavaScript/React Hooks'; + } + case 'other-not-javascript': { + return 'Update DOM and Draw New Frame'; + } + case 'render': { + return bar.event.name; + } + } +}; + +export const rankedBarMatchesSearch = ( + bar: RankedBarChartBar, + trimmedQuery: string, +): boolean => { + if (trimmedQuery === '') { + return true; + } + return getRankedBarDisplayLabel(bar) + .toLowerCase() + .includes(trimmedQuery.toLowerCase()); +}; + +export const filterSortedRankedBarsBySearch = ( + sortedBars: T[], + query: string, +): T[] => { + const trimmedQuery = query.trim(); + if (trimmedQuery === '') { + return sortedBars; + } + return sortedBars.filter((bar) => rankedBarMatchesSearch(bar, trimmedQuery)); +}; + +export const getSafeRankedWidthDenominator = (widthDenominator: number): number => + Math.max(widthDenominator, RANKED_BAR_CHART_WIDTH_DENOMINATOR_MIN); diff --git a/packages/scan/src/web/views/notifications/details-routes.tsx b/packages/scan/src/web/views/notifications/details-routes.tsx index 7bd7b4a2..1787f099 100644 --- a/packages/scan/src/web/views/notifications/details-routes.tsx +++ b/packages/scan/src/web/views/notifications/details-routes.tsx @@ -138,7 +138,10 @@ export const DetailsRoutes = () => { case 'render-visualization': { return ( - + ); } @@ -194,12 +197,14 @@ const TabLayout = ({ children }: { children: ReactNode }) => { ); } return ( -
-
+
+
{children}
diff --git a/packages/scan/src/web/views/notifications/render-bar-chart.tsx b/packages/scan/src/web/views/notifications/render-bar-chart.tsx index 1ec24736..525cbb29 100644 --- a/packages/scan/src/web/views/notifications/render-bar-chart.tsx +++ b/packages/scan/src/web/views/notifications/render-bar-chart.tsx @@ -14,6 +14,11 @@ import { HighlightStore, drawHighlights, } from '~core/notifications/outline-overlay'; +import { + filterSortedRankedBarsBySearch, + getRankedBarDisplayLabel, + getSafeRankedWidthDenominator, +} from '~web/utils/ranked-bar-chart-search'; import { ChevronRight } from './icons'; // todo: cleanup, convoluted ternaries @@ -34,9 +39,9 @@ export const fadeOutHighlights = () => { current: HighlightStore.value.current?.alpha === 0 ? // we want to only start fading from transition if current is done animating out - HighlightStore.value.transitionTo + HighlightStore.value.transitionTo : // if current doesn't exist then transition must exist - (HighlightStore.value.current ?? HighlightStore.value.transitionTo), + (HighlightStore.value.current ?? HighlightStore.value.transitionTo), }; return; } @@ -65,6 +70,7 @@ export const RenderBarChart = ({ const totalInteractionTime = getTotalTime(selectedEvent.timing); const nonRender = totalInteractionTime - selectedEvent.timing.renderTime; const [isProduction] = useState(getIsProduction()); + const [rankedSearchQuery, setRankedSearchQuery] = useState(''); const events = selectedEvent.groupedFiberRenders; const bars: Bars = events.map((event) => ({ event, @@ -80,7 +86,7 @@ export const RenderBarChart = ({ case 'interaction': { return ( (selectedEvent.timing.otherJSTime + selectedEvent.timing.renderTime) / - totalInteractionTime < + totalInteractionTime < 0.2 ); } @@ -121,7 +127,16 @@ export const RenderBarChart = ({ timer: null, }); - const totalBarTime = bars.reduce((prev, curr) => prev + curr.totalTime, 0); + const fullTotalBarTime = bars.reduce((prev, curr) => prev + curr.totalTime, 0); + const safeBarWidthDenominator = + getSafeRankedWidthDenominator(fullTotalBarTime); + + const sortedBars = bars.toSorted((a, b) => b.totalTime - a.totalTime); + const visibleTopLevelBars = filterSortedRankedBarsBySearch( + sortedBars, + rankedSearchQuery, + ); + const rankedSearchTrimmed = rankedSearchQuery.trim(); return (
@@ -152,18 +167,46 @@ export const RenderBarChart = ({ } })} - {bars - .toSorted((a, b) => b.totalTime - a.totalTime) - .map((bar) => ( - 0 && ( +
+ + + setRankedSearchQuery((event.target as HTMLInputElement).value) + } + placeholder="Search components…" + autoComplete="off" + spellcheck={false} + className={cn([ + 'w-full rounded-sm border border-[#27272A] bg-[#18181B] px-2.5 py-1.5', + 'text-xs text-white placeholder:text-[#6E6E77] select-text', + 'outline-none focus-visible:ring-2 focus-visible:ring-[#7521c8] focus-visible:border-transparent', + ])} /> - ))} +
+ )} + + {rankedSearchTrimmed && visibleTopLevelBars.length === 0 && bars.length > 0 ? ( +

+ No components match “{rankedSearchTrimmed}” +

+ ) : null} + + {visibleTopLevelBars.map((bar) => ( + + ))}
); }; @@ -184,7 +227,7 @@ const getTransitionState = (state: { const RenderBar = ({ bar, debouncedMouseEnter, - totalBarTime, + safeBarWidthDenominator, isProduction, bars, depth = 0, @@ -198,7 +241,7 @@ const RenderBar = ({ lastCallAt: number | null; }; }; - totalBarTime: number; + safeBarWidthDenominator: number; isProduction: boolean | null; }) => { const { setNotificationState, setRoute } = useNotificationsContext(); @@ -209,17 +252,17 @@ const RenderBar = ({ const parentBars = bars.filter((otherBar) => otherBar.kind === 'render' && bar.kind === 'render' ? bar.event.parents.has(otherBar.event.name) && - otherBar.event.name !== bar.event.name + otherBar.event.name !== bar.event.name : false, ); const missingParentNames = bar.kind === 'render' ? Array.from(bar.event.parents).filter( - (parentName) => - !bars.some( - (b) => b.kind === 'render' && b.event.name === parentName, - ), + (parentName) => + !bars.some( + (b) => b.kind === 'render' && b.event.name === parentName, + ), ) : []; @@ -317,9 +360,9 @@ const RenderBar = ({ kind: 'transition', current: HighlightStore.value.current ? { - alpha: 0, - ...HighlightStore.value.current, - } + alpha: 0, + ...HighlightStore.value.current, + } : null, transitionTo: { rects: stateRects, @@ -341,9 +384,9 @@ const RenderBar = ({ }, current: currentState ? { - alpha: 0, - ...currentState, - } + alpha: 0, + ...currentState, + } : null, }; } @@ -382,17 +425,17 @@ const RenderBar = ({
- {iife(() => { - switch (bar.kind) { - case 'other-frame-drop': { - return 'JavaScript, DOM updates, Draw Frame'; - } - case 'other-javascript': { - return 'JavaScript/React Hooks'; - } - case 'other-not-javascript': { - return 'Update DOM and Draw New Frame'; - } - case 'render': { - return bar.event.name; - } - } - })} + {getRankedBarDisplayLabel(bar)} {bar.kind === 'render' && isRenderMemoizable(bar.event) && (
diff --git a/packages/scan/src/web/widget/index.tsx b/packages/scan/src/web/widget/index.tsx index b7223a79..7a2a7f96 100644 --- a/packages/scan/src/web/widget/index.tsx +++ b/packages/scan/src/web/widget/index.tsx @@ -178,10 +178,21 @@ export const Widget = () => { const handleDrag = useCallback( (e: JSX.TargetedPointerEvent) => { - e.preventDefault(); + if (!refWidget.current) return; + + const eventTarget = e.target; + if (!(eventTarget instanceof HTMLElement)) return; - if (!refWidget.current || (e.target as HTMLElement).closest("button")) + if ( + eventTarget.closest("button") || + eventTarget.closest( + 'input, textarea, select, option, label, [contenteditable="true"]', + ) + ) { return; + } + + e.preventDefault(); const container = refWidget.current; const containerStyle = container.style; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77227920..f9ee3a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ importers: dependencies: '@vercel/speed-insights': specifier: ^1.1.0 - version: 1.2.0(next@15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 1.2.0(next@15.2.6(@playwright/test@1.58.2)(react@19.0.0))(react@19.0.0) devDependencies: '@changesets/cli': specifier: ^2.27.12 @@ -51,7 +51,7 @@ importers: version: 5.8.3 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)(jiti@1.21.7)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)) kitchen-sink: dependencies: @@ -140,7 +140,7 @@ importers: version: 4.4.4(@types/node@22.15.34)(jiti@1.21.7)(terser@5.43.1)(tsx@4.20.3) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)(jiti@1.21.7)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)) webextension-polyfill: specifier: ^0.12.0 version: 0.12.0 @@ -267,7 +267,7 @@ importers: specifier: ^1.0.0 version: 1.1.0 react-scan: - specifier: '>=0.5.2' + specifier: '>=0.5.3' version: link:../scan devDependencies: '@types/babel__core': @@ -293,7 +293,7 @@ importers: version: 1.5.0(@remix-run/react@2.16.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.8.3))(next@15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) '@vercel/speed-insights': specifier: ^1.1.0 - version: 1.2.0(next@15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 1.2.0(next@15.2.6(@playwright/test@1.58.2)(react@19.0.0))(react@19.0.0) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -310,7 +310,7 @@ importers: specifier: ^0.1.15 version: 0.1.15(@types/react@19.1.8)(react@19.0.0) react-scan: - specifier: ^0.5.2 + specifier: ^0.5.3 version: link:../scan zod: specifier: ^3.23.8 @@ -4622,7 +4622,7 @@ snapshots: next: 15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 - '@vercel/speed-insights@1.2.0(next@15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': + '@vercel/speed-insights@1.2.0(next@15.2.6(@playwright/test@1.58.2)(react@19.0.0))(react@19.0.0)': optionalDependencies: next: 15.2.6(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 @@ -6766,7 +6766,7 @@ snapshots: - tsx - utf-8-validate - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)(jiti@1.21.7)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.34)): dependencies: debug: 4.4.1 globrex: 0.1.2