Repository navigation
perf(webview): drop date-fns and react-icons from the webview bundle #2262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { SVGProps } from "react" | ||
|
|
||
| type BrandIconProps = Omit<SVGProps<SVGSVGElement>, "viewBox" | "xmlns"> | ||
|
|
||
| // Attribute order mirrors react-icons' `IconBase` so the serialized markup is | ||
| // byte-identical to what the library produced, which lets the spec assert exact | ||
| // equality instead of "looks the same". | ||
| const BrandIcon = ({ path, ...props }: BrandIconProps & { path: string }) => ( | ||
|
Check warning on line 8 in webview-ui/src/components/common/BrandIcons.tsx
|
||
| <svg | ||
| stroke="currentColor" | ||
| fill="currentColor" | ||
| strokeWidth="0" | ||
| role="img" | ||
| viewBox="0 0 24 24" | ||
| {...props} | ||
| height="1em" | ||
| width="1em" | ||
| xmlns="http://www.w3.org/2000/svg"> | ||
| <path d={path} /> | ||
| </svg> | ||
| ) | ||
|
|
||
| // Path data copied verbatim from react-icons 5.7.0 `react-icons/si`, which | ||
| // sources it from Simple Icons. Kept byte-identical so the marks render | ||
| // unchanged; do not hand-edit these strings. | ||
|
|
||
| const X_PATH = | ||
| "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" | ||
|
Check warning on line 28 in webview-ui/src/components/common/BrandIcons.tsx
|
||
|
|
||
| const DISCORD_PATH = | ||
| "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" | ||
|
Check warning on line 31 in webview-ui/src/components/common/BrandIcons.tsx
|
||
|
|
||
| const REDDIT_PATH = | ||
| "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" | ||
|
Check warning on line 34 in webview-ui/src/components/common/BrandIcons.tsx
|
||
|
|
||
| export const XIcon = (props: BrandIconProps) => <BrandIcon path={X_PATH} {...props} /> | ||
|
Check warning on line 36 in webview-ui/src/components/common/BrandIcons.tsx
|
||
|
|
||
| export const DiscordIcon = (props: BrandIconProps) => <BrandIcon path={DISCORD_PATH} {...props} /> | ||
|
Check warning on line 38 in webview-ui/src/components/common/BrandIcons.tsx
|
||
|
|
||
| export const RedditIcon = (props: BrandIconProps) => <BrandIcon path={REDDIT_PATH} {...props} /> | ||
|
Check warning on line 40 in webview-ui/src/components/common/BrandIcons.tsx
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import React from "react" | ||
| import { renderToStaticMarkup } from "react-dom/server" | ||
|
|
||
| import { DiscordIcon, RedditIcon, XIcon } from "../BrandIcons" | ||
|
|
||
| // Markup captured from react-icons 5.7.0 `react-icons/si` with the same props | ||
| // the Announcement passes, before the swap. Comparing the full serialized SVG | ||
| // proves the marks are byte-identical, not merely "looks similar". | ||
| const REACT_ICONS_MARKUP = { | ||
| X: '<svg stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"></path></svg>', | ||
| Discord: | ||
| '<svg stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"></path></svg>', | ||
| Reddit: '<svg stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"></path></svg>', | ||
| } as const | ||
|
|
||
| describe("BrandIcons", () => { | ||
| it.each([ | ||
| ["X", XIcon], | ||
| ["Discord", DiscordIcon], | ||
| ["Reddit", RedditIcon], | ||
| ] as const)("renders %s byte-identically to react-icons", (name, Icon) => { | ||
| const markup = renderToStaticMarkup(<Icon className="w-4 h-4" aria-hidden />) | ||
|
|
||
| expect(markup).toBe(REACT_ICONS_MARKUP[name]) | ||
| }) | ||
|
|
||
| it("keeps the accessible name on the wrapping link, not the icon", () => { | ||
| // The icons are decorative: the visible label lives in a screen-reader-only | ||
| // span, so the SVG must stay out of the accessibility tree. | ||
| const markup = renderToStaticMarkup(<XIcon className="w-4 h-4" aria-hidden />) | ||
|
|
||
| expect(markup).toContain('aria-hidden="true"') | ||
| expect(markup).not.toContain("<title") | ||
| }) | ||
|
|
||
| it("inherits color from the surrounding text", () => { | ||
| const markup = renderToStaticMarkup(<DiscordIcon />) | ||
|
|
||
| expect(markup).toContain('fill="currentColor"') | ||
| expect(markup).toContain('stroke="currentColor"') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,88 @@ | ||
| import { useMemo } from "react" | ||
| import { formatRelative } from "date-fns" | ||
|
|
||
| import type { McpErrorEntry } from "@roo-code/types" | ||
|
|
||
| // `Intl.DateTimeFormat` instances are immutable and locale-fixed, so build them | ||
| // once per module instead of per render. | ||
| const TIME_FORMAT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }) | ||
|
Check warning on line 7 in webview-ui/src/components/mcp/McpErrorRow.tsx
|
||
| const WEEKDAY_FORMAT = new Intl.DateTimeFormat("en-US", { weekday: "long" }) | ||
|
Check warning on line 8 in webview-ui/src/components/mcp/McpErrorRow.tsx
|
||
| const SHORT_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { | ||
|
Check warning on line 9 in webview-ui/src/components/mcp/McpErrorRow.tsx
|
||
| year: "numeric", | ||
| month: "2-digit", | ||
| day: "2-digit", | ||
| }) | ||
|
|
||
| const MILLISECONDS_IN_DAY = 86_400_000 | ||
|
|
||
| /** | ||
| * Whole calendar days from `baseDate` to `date`, in local time. | ||
| * | ||
| * Calendar days, not elapsed milliseconds: 23:59 and 00:01 on consecutive days | ||
| * are one day apart even though only two minutes elapsed. Local midnights are | ||
| * compared directly, so DST shifts (23h or 25h days) still round to one day. | ||
| * | ||
| * The inputs are copied and zeroed with `setHours` rather than rebuilt through | ||
| * `new Date(year, month, day)`, because that constructor maps years 0-99 onto | ||
| * 1900-1999: a date in year 99 would be silently read as 1999 and produce a | ||
| * delta of centuries instead of a day. | ||
| */ | ||
| const differenceInCalendarDays = (date: Date, baseDate: Date): number => { | ||
| const dateMidnight = new Date(date) | ||
| dateMidnight.setHours(0, 0, 0, 0) | ||
|
|
||
| const baseMidnight = new Date(baseDate) | ||
| baseMidnight.setHours(0, 0, 0, 0) | ||
|
|
||
| return Math.round((dateMidnight.getTime() - baseMidnight.getTime()) / MILLISECONDS_IN_DAY) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The doc comment above says DST days (23h or 25h) still round to one day, and |
||
| } | ||
|
|
||
| /** | ||
| * Render a timestamp the way `date-fns`' `formatRelative` did for the `en-US` | ||
| * locale: a calendar phrase for the surrounding two weeks, a short date beyond. | ||
| * | ||
| * | Distance to `baseDate` | Result | | ||
| * | ---------------------- | ----------------------- | | ||
| * | Same day | `today at 9:00 AM` | | ||
| * | Previous day | `yesterday at 9:00 AM` | | ||
| * | Next day | `tomorrow at 9:00 AM` | | ||
| * | Previous 2-6 days | `last Tuesday at 9:00 AM` | | ||
| * | Next 2-6 days | `Saturday at 9:00 AM` | | ||
| * | Anything else | `10/22/2026` | | ||
| * | ||
| * The output is deliberately fixed to `en-US` because that is what the previous | ||
| * implementation produced: `formatRelative` was called without a `locale` | ||
| * option, so it used date-fns' `enUS` default regardless of the UI language. | ||
| * | ||
| * @throws {RangeError} When `date` is an invalid date. | ||
| */ | ||
| export const formatRelativeTimestamp = (date: Date, baseDate: Date): string => { | ||
| const dayDelta = differenceInCalendarDays(date, baseDate) | ||
|
|
||
| if (Number.isNaN(dayDelta)) { | ||
| throw new RangeError("Invalid time value") | ||
| } | ||
|
|
||
| const time = TIME_FORMAT.format(date) | ||
|
|
||
| if (dayDelta < -6 || dayDelta >= 7) { | ||
| return SHORT_DATE_FORMAT.format(date) | ||
| } | ||
| if (dayDelta < -1) { | ||
| return `last ${WEEKDAY_FORMAT.format(date)} at ${time}` | ||
| } | ||
| if (dayDelta < 0) { | ||
| return `yesterday at ${time}` | ||
| } | ||
| if (dayDelta < 1) { | ||
| return `today at ${time}` | ||
| } | ||
| if (dayDelta < 2) { | ||
| return `tomorrow at ${time}` | ||
| } | ||
|
|
||
| return `${WEEKDAY_FORMAT.format(date)} at ${time}` | ||
| } | ||
|
|
||
| type McpErrorRowProps = { | ||
| error: McpErrorEntry | ||
| } | ||
|
|
@@ -25,7 +105,7 @@ | |
| {error.message} | ||
| </div> | ||
| <div className="text-xs text-vscode-descriptionForeground"> | ||
| {formatRelative(error.timestamp, new Date())} | ||
| {formatRelativeTimestamp(new Date(error.timestamp), new Date())} | ||
| </div> | ||
| </div> | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'd lean toward keeping
react-iconsfor these three. The PR notes the bundle already tree-shakes, so the entry-chunk win is only the date-fns half, and the icon half mainly saves install size. CI cachesnode_modulesanyway. Is that worth owning hand-copied paths plus a golden-string spec? Happy to hear if I'm missing a dev-server or install-time cost.