From 19a8e482fc6ae099456e30cbd8da9ef04e4073da Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 18 Aug 2026 16:28:00 -0700 Subject: [PATCH] perf(web): keep react-virtualized out of the entry chunk react-virtualized is ~638 KB of source and was landing in the entry chunk for every visitor through two unrelated edges, neither of which needs it at first paint. 1. harmony's FilterButton imported it statically, but only renders VirtualizedOptionsList behind the opt-in `virtualized` prop -- 3 call sites across the whole web app. Split into its own module and loaded on demand behind a Suspense boundary. The menu only renders while open, so the import starts when a virtualized filter menu is actually opened. Note the `as typeof import(...)` on the lazy const: React.lazy erases the component's `` generic, so it would otherwise resolve to the `string` instantiation and lose type safety at call sites. 2. TransactionDetailsContent imported `isChangePositive` -- a five-line predicate over two enum values -- from AudioTransactionsTable. That module imports components/table, which imports react-virtualized. Because the TransactionDetails modal is registered eagerly in registerNiceModals, the whole virtualized table came along for a function that checks whether a transaction increases a balance. Moved the predicate to its own module and re-exported it from the table for backwards compatibility. Co-Authored-By: Claude Opus 5 --- .../button/FilterButton/FilterButton.tsx | 40 +++++++++--- .../FilterButton/FilterButtonOptionsList.tsx | 60 +----------------- .../FilterButtonVirtualizedOptionsList.tsx | 63 +++++++++++++++++++ .../AudioTransactionsTable.tsx | 7 +-- .../isChangePositive.ts | 21 +++++++ .../components/TransactionDetailsContent.tsx | 2 +- 6 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 packages/harmony/src/components/button/FilterButton/FilterButtonVirtualizedOptionsList.tsx create mode 100644 packages/web/src/components/audio-transactions-table/isChangePositive.ts diff --git a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx index f21b6a14217..8fde04e6cf8 100644 --- a/packages/harmony/src/components/button/FilterButton/FilterButton.tsx +++ b/packages/harmony/src/components/button/FilterButton/FilterButton.tsx @@ -5,7 +5,9 @@ import { useCallback, useEffect, useMemo, - Ref + Ref, + lazy, + Suspense } from 'react' import { CSSObject, useTheme } from '@emotion/react' @@ -20,9 +22,27 @@ import { Text } from '~harmony/components/text/Text' import { useControlled } from '~harmony/hooks/useControlled' import { IconCaretDown, IconCloseAlt, IconSearch } from '~harmony/icons' -import { OptionsList, VirtualizedOptionsList } from './FilterButtonOptionsList' +import { OptionsList } from './FilterButtonOptionsList' import { FilterButtonProps } from './types' +/** + * `react-virtualized` is ~638 KB of source and is only needed when a consumer + * opts in with the `virtualized` prop (3 call sites across the web app). It was + * previously a static import, so every surface using FilterButton — and + * FilterButton is reachable from the eager app shell via PaymentMethod — paid + * for it. Loaded on demand instead; the menu is only rendered while open, so + * the import starts when a virtualized filter menu is actually opened. + */ +const VirtualizedOptionsList = lazy(() => + import('./FilterButtonVirtualizedOptionsList').then((m) => ({ + // `lazy()` erases the component's `` generic, so it + // resolves to the `string` instantiation. Re-declaring the generic on the + // lazy const keeps call sites type-safe; the generic is erased at runtime + // either way, so this only restores what lazy() dropped. + default: m.VirtualizedOptionsList as typeof m.VirtualizedOptionsList + })) +) as typeof import('./FilterButtonVirtualizedOptionsList').VirtualizedOptionsList + const messages = { noMatches: 'No matches' } @@ -228,13 +248,15 @@ export const FilterButton = forwardRef(function FilterButton< const optionElements = filteredOptions ? ( virtualized ? ( - + + + ) : ( = { onChange: (value: Value) => void } -type VirtualizedOptionsListProps = { - options: FilterButtonOptionType[] - optionRefs: RefObject - onChange: (value: Value) => void - height: CSSObject['height'] - width: CSSObject['width'] -} - -export const VirtualizedOptionsList = ({ - options, - optionRefs, - onChange, - height = 100, - width = 100 -}: VirtualizedOptionsListProps) => { - const [rowHeight, setRowHeight] = useState(50) - useEffect(() => { - if (optionRefs.current?.[0]) { - setRowHeight(optionRefs.current?.[0].offsetHeight) - } - }, [optionRefs]) - - const renderItem = useCallback( - ({ index, style }: ListRowProps) => { - const option = options[index] - - return ( - { - if (optionRefs && optionRefs.current && el) { - optionRefs.current[index] = el - } - }} - key={option.value} - {...option} - onChange={onChange} - /> - ) - }, - [onChange, optionRefs, options] - ) - - return ( - - ) -} - export const OptionsList = ({ options, isOpen, diff --git a/packages/harmony/src/components/button/FilterButton/FilterButtonVirtualizedOptionsList.tsx b/packages/harmony/src/components/button/FilterButton/FilterButtonVirtualizedOptionsList.tsx new file mode 100644 index 00000000000..51efac4b450 --- /dev/null +++ b/packages/harmony/src/components/button/FilterButton/FilterButtonVirtualizedOptionsList.tsx @@ -0,0 +1,63 @@ +import { useState, useCallback, useEffect, RefObject } from 'react' + +import { CSSObject } from '@emotion/react' +import { List, ListRowProps } from 'react-virtualized' + +import { MenuItem } from '~harmony/components/internal/MenuItem' + +import { FilterButtonOptionType } from './types' + +type VirtualizedOptionsListProps = { + options: FilterButtonOptionType[] + optionRefs: RefObject + onChange: (value: Value) => void + height: CSSObject['height'] + width: CSSObject['width'] +} + +export const VirtualizedOptionsList = ({ + options, + optionRefs, + onChange, + height = 100, + width = 100 +}: VirtualizedOptionsListProps) => { + const [rowHeight, setRowHeight] = useState(50) + useEffect(() => { + if (optionRefs.current?.[0]) { + setRowHeight(optionRefs.current?.[0].offsetHeight) + } + }, [optionRefs]) + + const renderItem = useCallback( + ({ index, style }: ListRowProps) => { + const option = options[index] + + return ( + { + if (optionRefs && optionRefs.current && el) { + optionRefs.current[index] = el + } + }} + key={option.value} + {...option} + onChange={onChange} + /> + ) + }, + [onChange, optionRefs, options] + ) + + return ( + + ) +} diff --git a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx index 3e44f2f3594..61fc81ef1a6 100644 --- a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx +++ b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx @@ -61,12 +61,7 @@ const defaultColumns: AudioTransactionsTableColumn[] = [ 'spacer2' ] -export const isChangePositive = (tx: TransactionDetails) => { - return ( - tx.transactionType === TransactionType.PURCHASE || - tx.method === TransactionMethod.RECEIVE - ) -} +export { isChangePositive } from './isChangePositive' // Cell Render Functions const renderTransactionTypeCell = (cellInfo: TransactionCell) => { diff --git a/packages/web/src/components/audio-transactions-table/isChangePositive.ts b/packages/web/src/components/audio-transactions-table/isChangePositive.ts new file mode 100644 index 00000000000..5eb20d22ef5 --- /dev/null +++ b/packages/web/src/components/audio-transactions-table/isChangePositive.ts @@ -0,0 +1,21 @@ +import { + TransactionDetails, + TransactionMethod, + TransactionType +} from '@audius/common/store' + +/** + * Whether a transaction increases the user's balance. + * + * Lives in its own module rather than in `AudioTransactionsTable` so that + * consumers needing only this predicate don't pull in the table — which imports + * `components/table` and, through it, `react-virtualized` (~638 KB of source). + * `TransactionDetailsContent` is reachable from the eagerly-registered + * TransactionDetails modal, so that edge landed the whole table in the entry chunk. + */ +export const isChangePositive = (tx: TransactionDetails) => { + return ( + tx.transactionType === TransactionType.PURCHASE || + tx.method === TransactionMethod.RECEIVE + ) +} diff --git a/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx b/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx index e34f5db4aa3..c219fdf95e5 100644 --- a/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx +++ b/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx @@ -21,7 +21,7 @@ import { import cn from 'classnames' import { AudioTransactionIcon } from 'components/audio-transaction-icon' -import { isChangePositive } from 'components/audio-transactions-table/AudioTransactionsTable' +import { isChangePositive } from 'components/audio-transactions-table/isChangePositive' import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' import { getChallengeConfig } from 'pages/rewards-page/config'