Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/scan/src/web/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
97 changes: 97 additions & 0 deletions packages/scan/src/web/utils/ranked-bar-chart-search.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
54 changes: 54 additions & 0 deletions packages/scan/src/web/utils/ranked-bar-chart-search.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends RankedBarChartBar>(
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);
8 changes: 5 additions & 3 deletions packages/scan/src/web/views/notifications/details-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,14 @@ const TabLayout = ({ children }: { children: ReactNode }) => {
);
}
return (
<div className={cn([`w-full h-full flex flex-col gap-y-2`])}>
<div className={cn(['h-[50px] w-full'])}>
<div className={cn(['flex h-full min-h-0 w-full flex-col'])}>
<div className={cn(['w-full shrink-0'])}>
<NotificationTabs selectedEvent={notificationState.selectedEvent} />
</div>
<div
className={cn(['h-calc(100%-50px) flex flex-col overflow-y-auto px-3'])}
className={cn([
'flex min-h-0 flex-1 flex-col overflow-y-auto px-3 pt-2',
])}
>
{children}
</div>
Expand Down
94 changes: 62 additions & 32 deletions packages/scan/src/web/views/notifications/render-bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('');
Comment thread
cursor[bot] marked this conversation as resolved.
const events = selectedEvent.groupedFiberRenders;
const bars: Bars = events.map((event) => ({
event,
Expand Down Expand Up @@ -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 (
<div className={cn(['flex flex-col h-full w-full gap-y-1'])}>
Expand Down Expand Up @@ -152,18 +167,46 @@ export const RenderBarChart = ({
}
})}

{bars
.toSorted((a, b) => b.totalTime - a.totalTime)
.map((bar) => (
<RenderBar
key={bar.kind === 'render' ? bar.event.id : bar.kind}
bars={bars}
bar={bar}
debouncedMouseEnter={debouncedMouseEnter}
totalBarTime={totalBarTime}
isProduction={isProduction}
{bars.length > 0 && (
<div className={cn(['sticky top-0 z-[2] bg-[#0A0A0A] pb-2'])}>
<label className="sr-only" htmlFor="react-scan-ranked-search">
Search ranked components
</label>
<input
id="react-scan-ranked-search"
type="search"
value={rankedSearchQuery}
onInput={(event) =>
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',
])}
/>
))}
</div>
)}

{rankedSearchTrimmed && visibleTopLevelBars.length === 0 && bars.length > 0 ? (
<p className={cn(['text-xs text-[#A1A1AA] py-2'])}>
No components match &ldquo;{rankedSearchTrimmed}&rdquo;
</p>
) : null}

{visibleTopLevelBars.map((bar) => (
<RenderBar
key={bar.kind === 'render' ? bar.event.id : bar.kind}
bars={bars}
bar={bar}
debouncedMouseEnter={debouncedMouseEnter}
safeBarWidthDenominator={safeBarWidthDenominator}
isProduction={isProduction}
/>
))}
</div>
);
};
Expand All @@ -184,7 +227,7 @@ const getTransitionState = (state: {
const RenderBar = ({
bar,
debouncedMouseEnter,
totalBarTime,
safeBarWidthDenominator,
isProduction,
bars,
depth = 0,
Expand All @@ -198,7 +241,7 @@ const RenderBar = ({
lastCallAt: number | null;
};
};
totalBarTime: number;
safeBarWidthDenominator: number;
isProduction: boolean | null;
}) => {
const { setNotificationState, setRoute } = useNotificationsContext();
Expand All @@ -223,6 +266,8 @@ const RenderBar = ({
)
: [];

const safeWidthDenominator = safeBarWidthDenominator;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const handleBarClick = () => {
if (bar.kind === 'render') {
setNotificationState((prev) => ({
Expand Down Expand Up @@ -382,7 +427,7 @@ const RenderBar = ({
<div
style={{
minWidth: 'fit-content',
width: `${(bar.totalTime / totalBarTime) * 100}%`,
width: `${(bar.totalTime / safeWidthDenominator) * 100}%`,
}}
className={cn([
'flex items-center rounded-sm text-white text-xs h-[28px] shrink-0',
Expand All @@ -403,22 +448,7 @@ const RenderBar = ({
>
<div className="flex items-center gap-x-2 min-w-0 w-full">
<span className={cn(['truncate'])}>
{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)}
</span>
{bar.kind === 'render' && isRenderMemoizable(bar.event) && (
<div
Expand Down Expand Up @@ -502,7 +532,7 @@ const RenderBar = ({
key={i}
bar={parentBar}
debouncedMouseEnter={debouncedMouseEnter}
totalBarTime={totalBarTime}
safeBarWidthDenominator={safeBarWidthDenominator}
isProduction={isProduction}
bars={bars}
/>
Expand Down
15 changes: 13 additions & 2 deletions packages/scan/src/web/widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,21 @@ export const Widget = () => {

const handleDrag = useCallback(
(e: JSX.TargetedPointerEvent<HTMLDivElement>) => {
e.preventDefault();
if (!refWidget.current) return;

const eventTarget = e.target;
if (!(eventTarget instanceof HTMLElement)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drag breaks when pointer target is SVG element

Medium Severity

The instanceof HTMLElement guard rejects all SVG-based targets (like SVGSVGElement, SVGPathElement, SVGUseElement), causing the handleDrag function to return early without initiating a drag. SVG elements inherit from Element, not HTMLElement. The widget uses SVG icons extensively (via the Icon component rendering <svg> with <use>, ChevronRight with <path>, etc.). While most are currently inside buttons, the check is logically too narrow — instanceof Element would correctly allow the closest() calls to function for both HTML and SVG targets.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!(eventTarget instanceof HTMLElement)) return;
if (!(eventTarget instanceof Element)) return;

SVG elements in the widget (like the close button icon) fail the HTMLElement instanceof check, preventing drag initialization when clicking on SVG-based UI elements

Fix on Vercel


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;
Expand Down
Loading