From 94ce5c4ed8a3a4cd99d3f06267a31d15758a1a97 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 16:59:09 -0700 Subject: [PATCH 01/12] perf(web): lazy-load the Optimizely SDK Only createOptimizelyClient needs it, so import it there and drop it from the entry chunk. common's import becomes type-only. Co-Authored-By: Claude Opus 5.5 --- .../common/src/services/remote-config/remote-config.ts | 2 +- .../src/services/remote-config/remote-config-instance.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/common/src/services/remote-config/remote-config.ts b/packages/common/src/services/remote-config/remote-config.ts index dd2a437d3e5..9ae5b87c685 100644 --- a/packages/common/src/services/remote-config/remote-config.ts +++ b/packages/common/src/services/remote-config/remote-config.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events' -import optimizely from '@optimizely/optimizely-sdk' +import type optimizely from '@optimizely/optimizely-sdk' import { ID } from '~/models' import { Nullable } from '~/utils' diff --git a/packages/web/src/services/remote-config/remote-config-instance.ts b/packages/web/src/services/remote-config/remote-config-instance.ts index 37f2bfe1226..1f7fe274efd 100644 --- a/packages/web/src/services/remote-config/remote-config-instance.ts +++ b/packages/web/src/services/remote-config/remote-config-instance.ts @@ -1,5 +1,5 @@ import { remoteConfig } from '@audius/common/services' -import optimizely, { Config } from '@optimizely/optimizely-sdk' +import type { Config } from '@optimizely/optimizely-sdk' import { isEmpty } from 'lodash' import { env } from 'services/env' @@ -36,6 +36,9 @@ export const remoteConfigInstance = remoteConfig({ console.error(new Error('Optimizely failed to load')) } + // Loaded lazily so the SDK stays out of the entry chunk + const { default: optimizely } = await import('@optimizely/optimizely-sdk') + optimizely.setLogLevel('warn') return optimizely.createInstance({ datafile, errorHandler: { @@ -56,6 +59,7 @@ export const remoteConfigInstance = remoteConfig({ FEATURE_FLAG_LOCAL_STORAGE_SESSION_KEY, id.toString() ), - setLogLevel: () => optimizely.setLogLevel('warn'), + // Log level is set in createOptimizelyClient once the SDK has loaded + setLogLevel: () => {}, environment: env.ENVIRONMENT }) From 8f2d4c3d7eac404fd7aaf25b92581917958a0571 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 16:59:37 -0700 Subject: [PATCH 02/12] perf(web): lazy-load the play queue popover The popover pulls in the drag-and-drop library and only renders after the queue button is clicked. Load it on first open, prefetching on hover. Co-Authored-By: Claude Opus 5.5 --- .../play-bar/queue-button/QueueButton.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/play-bar/queue-button/QueueButton.tsx b/packages/web/src/components/play-bar/queue-button/QueueButton.tsx index 098acc62f16..2ba5984062d 100644 --- a/packages/web/src/components/play-bar/queue-button/QueueButton.tsx +++ b/packages/web/src/components/play-bar/queue-button/QueueButton.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { lazy, Suspense, useCallback, useRef, useState } from 'react' import { Name } from '@audius/common/models' import { playbackSelectors } from '@audius/common/store' @@ -6,10 +6,17 @@ import { Flex, IconButton, IconIndent, Tooltip } from '@audius/harmony' import { useSelector } from 'react-redux' import { make, useRecord } from 'common/store/analytics/actions' -import { QueuePopover } from 'components/queue-popover' const { getPlaybackQueue } = playbackSelectors +const loadQueuePopover = () => import('components/queue-popover') +const QueuePopover = lazy(() => + loadQueuePopover().then((m) => ({ default: m.QueuePopover })) +) +const prefetchQueuePopover = () => { + loadQueuePopover().catch(() => {}) +} + const messages = { queue: 'Queue' } @@ -17,6 +24,9 @@ const messages = { export const QueueButton = () => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) + // Only mount (and load) the popover once it has been opened + const [hasOpened, setHasOpened] = useState(false) + if (isOpen && !hasOpened) setHasOpened(true) const queue = useSelector(getPlaybackQueue) const record = useRecord() @@ -48,7 +58,12 @@ export const QueueButton = () => { return ( <> - + { - + {hasOpened ? ( + + + + ) : null} ) } From f9efa444378581212af6ce7fcd9ff43519dd947a Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:00:15 -0700 Subject: [PATCH 03/12] perf(web): lazy-load the notification panel and page The desktop panel now mounts on first hover or open of the bell, which also kicks off the notifications fetch. The mobile-web notification page becomes a lazy route like its neighbours. Co-Authored-By: Claude Opus 5.5 --- packages/web/src/app/web-player/WebPlayer.tsx | 6 ++- .../nav/desktop/NotificationsButton.tsx | 53 +++++++++++++++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index ade608f982f..3d43f9e1d32 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -62,7 +62,6 @@ import { HeaderContextConsumer } from 'components/header/mobile/HeaderContextPro import Navigator from 'components/nav/Navigator' import TopLevelPage from 'components/nav/mobile/TopLevelPage' import Notice from 'components/notice/Notice' -import { NotificationPage } from 'components/notification' import PlayBarProvider from 'components/play-bar/PlayBarProvider' import { RewardClaimedToast } from 'components/reward-claimed-toast/RewardClaimedToast' import { USDCBalanceFetcher } from 'components/usdc-balance-fetcher/USDCBalanceFetcher' @@ -177,6 +176,11 @@ const NotFoundPage = lazy(() => default: m.NotFoundPage })) ) +const NotificationPage = lazy(() => + import('components/notification/NotificationPage').then((m) => ({ + default: m.NotificationPage + })) +) const NotificationUsersPage = lazy(() => import('pages/notification-users-page/NotificationUsersPage').then((m) => ({ default: m.NotificationUsersPage diff --git a/packages/web/src/components/nav/desktop/NotificationsButton.tsx b/packages/web/src/components/nav/desktop/NotificationsButton.tsx index 49365883558..97887f40285 100644 --- a/packages/web/src/components/nav/desktop/NotificationsButton.tsx +++ b/packages/web/src/components/nav/desktop/NotificationsButton.tsx @@ -1,4 +1,13 @@ -import { MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react' +import { + lazy, + MouseEvent, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { selectIsAccountComplete, @@ -12,13 +21,18 @@ import { Flex, IconNotificationOn, NotificationCount } from '@audius/harmony' import { useSearchParam } from 'react-use' import { make, useRecord } from 'common/store/analytics/actions' -import { NotificationPanel } from 'components/notification' import { AnnouncementModal } from 'components/notification/AnnouncementModal' import { useRequiresAccountFn } from 'hooks/useRequiresAccount' import { canAccess } from './NavHeader' import { NavHeaderButton } from './NavHeaderButton' +const loadNotificationPanel = () => + import('components/notification/NotificationPanel') +const NotificationPanel = lazy(() => + loadNotificationPanel().then((m) => ({ default: m.NotificationPanel })) +) + const messages = { label: (count: number) => `${count} unread notifications` } @@ -35,6 +49,11 @@ export const NotificationsButton = ({ size }: NotificationsButtonProps) => { }) const buttonRef = useRef(null) const { isOpen, onOpen, onClose } = useNotificationModal() + // Mount the panel on first hover or open. Mounting starts the + // notifications fetch, so hovering warms both the code and the data. + const [shouldMountPanel, setShouldMountPanel] = useState(false) + if (isOpen && !shouldMountPanel) setShouldMountPanel(true) + const handleMouseEnter = useCallback(() => setShouldMountPanel(true), []) const record = useRecord() const { requiresAccount } = useRequiresAccountFn(undefined, 'account') @@ -85,31 +104,47 @@ export const NotificationsButton = ({ size }: NotificationsButtonProps) => { ) if (shouldShowCount) { return ( - onOpen()}> + onOpen()} + onMouseEnter={handleMouseEnter} + > {button} ) } - return {button} + return ( + + {button} + + ) }, [ notificationCount, handleToggleNotificationPanel, isOpen, shouldShowCount, onOpen, + handleMouseEnter, size ]) return ( <> {notificationButton} - + {shouldMountPanel ? ( + + + + ) : null} ) From 5ffa40ee807ab40cec2ad9270352545e9ac3fd11 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:00:48 -0700 Subject: [PATCH 04/12] perf(web): move settings SubPage enum to a types file WebPlayer only needs the enum, but importing it from the mobile settings page pulled the whole page into the entry chunk. Co-Authored-By: Claude Opus 5.5 --- packages/web/src/app/web-player/WebPlayer.tsx | 2 +- packages/web/src/pages/settings-page/SettingsPage.tsx | 6 ++---- .../settings-page/components/mobile/SettingsPage.tsx | 9 +-------- packages/web/src/pages/settings-page/types.ts | 7 +++++++ 4 files changed, 11 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/pages/settings-page/types.ts diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index 3d43f9e1d32..52ae4248d86 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -72,7 +72,7 @@ import { usePlaybackPositionPolling } from 'hooks/usePlaybackPositionPolling' import { usePlaybackRatePersistence } from 'hooks/usePlaybackRatePersistence' import { MAIN_CONTENT_ID, MainContentContext } from 'pages/MainContentContext' import { TableType } from 'pages/pay-and-earn-page/types' -import { SubPage } from 'pages/settings-page/components/mobile/SettingsPage' +import { SubPage } from 'pages/settings-page/types' import { remoteConfigInstance } from 'services/remote-config/remote-config-instance' import { SsrContext } from 'ssr/SsrContext' import { showCookieBanner } from 'store/application/ui/cookieBanner/actions' diff --git a/packages/web/src/pages/settings-page/SettingsPage.tsx b/packages/web/src/pages/settings-page/SettingsPage.tsx index 7daff85c536..914181ccc51 100644 --- a/packages/web/src/pages/settings-page/SettingsPage.tsx +++ b/packages/web/src/pages/settings-page/SettingsPage.tsx @@ -3,10 +3,8 @@ import { RefObject } from 'react' import { useIsMobile } from 'hooks/useIsMobile' import { SettingsPage as DesktopSettingsPage } from './components/desktop/SettingsPage' -import { - SettingsPage as MobileSettingsPage, - SubPage -} from './components/mobile/SettingsPage' +import { SettingsPage as MobileSettingsPage } from './components/mobile/SettingsPage' +import { SubPage } from './types' type SettingsPageProps = { containerRef: RefObject diff --git a/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx b/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx index eda39c48043..b013534609e 100644 --- a/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx +++ b/packages/web/src/pages/settings-page/components/mobile/SettingsPage.tsx @@ -42,6 +42,7 @@ import NavContext, { LeftPreset } from 'components/nav/mobile/NavContext' import Page from 'components/page/Page' import { useProfilePicture } from 'hooks/useProfilePicture' import useScrollToTop from 'hooks/useScrollToTop' +import { SubPage } from 'pages/settings-page/types' import { isDarkMode, THEME_KEY, @@ -71,14 +72,6 @@ const { PAYMENTS_PAGE } = route -export enum SubPage { - ACCOUNT = 'account', - NOTIFICATIONS = 'notifications', - ABOUT = 'about', - CHANGE_PASSWORD = 'change password', - CHANGE_EMAIL = 'change email' -} - const messages = { pageTitle: 'Settings', appearanceTitle: 'Appearance', diff --git a/packages/web/src/pages/settings-page/types.ts b/packages/web/src/pages/settings-page/types.ts new file mode 100644 index 00000000000..6998d1dc8e7 --- /dev/null +++ b/packages/web/src/pages/settings-page/types.ts @@ -0,0 +1,7 @@ +export enum SubPage { + ACCOUNT = 'account', + NOTIFICATIONS = 'notifications', + ABOUT = 'about', + CHANGE_PASSWORD = 'change password', + CHANGE_EMAIL = 'change email' +} From 39054771fd80190357e22814bfe81c29310875bd Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:01:44 -0700 Subject: [PATCH 05/12] perf(web): persist redux state to localStorage instead of localforage Search history and library filters are tiny, so localStorage is enough. Existing values are copied from localforage on first read of each key; localforage is only imported for that one-time copy. Co-Authored-By: Claude Opus 5.5 --- packages/web/src/store/persistStorage.test.ts | 54 ++++++++++++++++++ packages/web/src/store/persistStorage.ts | 55 +++++++++++++++++++ packages/web/src/store/reducers.ts | 4 +- 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/store/persistStorage.test.ts create mode 100644 packages/web/src/store/persistStorage.ts diff --git a/packages/web/src/store/persistStorage.test.ts b/packages/web/src/store/persistStorage.test.ts new file mode 100644 index 00000000000..7274a6d4196 --- /dev/null +++ b/packages/web/src/store/persistStorage.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { persistStorage } from './persistStorage' + +const localforageItems = new Map() +const getItemMock = vi.fn(async (key: string) => localforageItems.get(key)) + +vi.mock('localforage', () => ({ + default: { getItem: (key: string) => getItemMock(key) } +})) + +describe('persistStorage', () => { + beforeEach(() => { + window.localStorage.clear() + localforageItems.clear() + getItemMock.mockClear() + }) + + it('reads and writes localStorage', async () => { + await persistStorage.setItem('persist:search', '{"history":"[]"}') + expect(await persistStorage.getItem('persist:search')).toBe( + '{"history":"[]"}' + ) + expect(getItemMock).not.toHaveBeenCalled() + }) + + it('copies a value from localforage once', async () => { + localforageItems.set('persist:search', '{"history":"[\\"a\\"]"}') + + expect(await persistStorage.getItem('persist:search')).toBe( + '{"history":"[\\"a\\"]"}' + ) + expect(window.localStorage.getItem('persist:search')).toBe( + '{"history":"[\\"a\\"]"}' + ) + expect(await persistStorage.getItem('persist:search')).toBe( + '{"history":"[\\"a\\"]"}' + ) + expect(getItemMock).toHaveBeenCalledTimes(1) + }) + + it('only checks localforage once when it has nothing', async () => { + expect(await persistStorage.getItem('persist:library-page')).toBeNull() + expect(await persistStorage.getItem('persist:library-page')).toBeNull() + expect(getItemMock).toHaveBeenCalledTimes(1) + }) + + it('does not restore from localforage after a removal', async () => { + localforageItems.set('persist:search', 'old') + await persistStorage.getItem('persist:search') + await persistStorage.removeItem('persist:search') + expect(await persistStorage.getItem('persist:search')).toBeNull() + }) +}) diff --git a/packages/web/src/store/persistStorage.ts b/packages/web/src/store/persistStorage.ts new file mode 100644 index 00000000000..30293796b1a --- /dev/null +++ b/packages/web/src/store/persistStorage.ts @@ -0,0 +1,55 @@ +import type { Storage } from 'redux-persist' + +// redux-persist used to write through localforage (IndexedDB). Keys that +// have not been read from localStorage yet are copied over once, and +// localforage is only loaded for that copy. +const MIGRATED_PREFIX = 'persist-migrated-from-localforage:' + +const getLocalStorage = () => { + try { + return typeof window === 'undefined' ? null : window.localStorage + } catch { + return null + } +} + +const migrateFromLocalforage = async ( + localStorage: globalThis.Storage, + key: string +): Promise => { + let value: string | null = null + try { + const { default: localforage } = await import('localforage') + const stored = await localforage.getItem(key) + if (typeof stored === 'string') { + value = stored + localStorage.setItem(key, stored) + } + } catch (e) { + console.warn(`Failed to migrate ${key} from localforage`, e) + } + try { + localStorage.setItem(MIGRATED_PREFIX + key, 'true') + } catch { + // Storage full or unavailable; we'll retry next load + } + return value +} + +export const persistStorage: Storage = { + getItem: async (key: string) => { + const localStorage = getLocalStorage() + if (!localStorage) return null + const value = localStorage.getItem(key) + if (value !== null || localStorage.getItem(MIGRATED_PREFIX + key)) { + return value + } + return migrateFromLocalforage(localStorage, key) + }, + setItem: async (key: string, value: string) => { + getLocalStorage()?.setItem(key, value) + }, + removeItem: async (key: string) => { + getLocalStorage()?.removeItem(key) + } +} diff --git a/packages/web/src/store/reducers.ts b/packages/web/src/store/reducers.ts index bb8f53410cc..45642158e68 100644 --- a/packages/web/src/store/reducers.ts +++ b/packages/web/src/store/reducers.ts @@ -2,7 +2,6 @@ import { remoteConfigReducer as remoteConfig, reducers as clientStoreReducers } from '@audius/common/store' -import localForage from 'localforage' import { combineReducers } from 'redux' import backend from 'common/store/backend/reducer' @@ -18,9 +17,10 @@ import scrollLock from 'store/application/ui/scrollLock/reducer' import userListModal from 'store/application/ui/userListModal/slice' import dragndrop from 'store/dragndrop/slice' import error from 'store/errors/reducers' +import { persistStorage } from 'store/persistStorage' const createRootReducer = () => { - const commonStoreReducers = clientStoreReducers(localForage) + const commonStoreReducers = clientStoreReducers(persistStorage) return combineReducers({ // Common store From aaf74e5cfd226a4eef37e8887e233398ca978362 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:03:24 -0700 Subject: [PATCH 06/12] perf(sdk): lazy-load tus-js-client and the Node file-type path Both are only needed during uploads, but were evaluated at startup on web and mobile. Co-Authored-By: Claude Opus 5.5 --- .../sdk/src/sdk/services/Storage/Storage.ts | 19 +++++++++++++----- packages/sdk/src/sdk/types/File.ts | 20 +++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/sdk/services/Storage/Storage.ts b/packages/sdk/src/sdk/services/Storage/Storage.ts index ebac3693c90..dcbdd661940 100644 --- a/packages/sdk/src/sdk/services/Storage/Storage.ts +++ b/packages/sdk/src/sdk/services/Storage/Storage.ts @@ -1,4 +1,4 @@ -import * as tus from 'tus-js-client' +import type { Upload } from 'tus-js-client' import { productionConfig } from '../../config/production' import fetch from '../../utils/fetch' @@ -18,6 +18,12 @@ import type { UploadFileParams } from './types' +// tus-js-client is only needed once an upload starts +const loadTus = async () => { + const tus: any = await import('tus-js-client') + return ('Upload' in tus ? tus : tus.default) as typeof import('tus-js-client') +} + const MAX_TRACK_TRANSCODE_TIMEOUT = 3600000 // 1 hour const MAX_TRACK_TRANSCODE_NO_PROGRESS_TIMEOUT = 1200000 // 20 minutes const MAX_IMAGE_RESIZE_TIMEOUT_MS = 300000 // 5 minutes @@ -46,7 +52,7 @@ export class Storage implements StorageService { */ uploadFile({ file, onProgress, metadata }: UploadFileParams) { const ac = new AbortController() - let upload: tus.Upload | undefined + let upload: Upload | undefined let uploadPromise: Promise | undefined let cachedTotal = 0 @@ -56,13 +62,16 @@ export class Storage implements StorageService { return uploadPromise } uploadPromise = new Promise((resolve, reject) => { - this.storageNodeSelector - .getSelectedNode() - .then((selectedNode) => { + Promise.all([this.storageNodeSelector.getSelectedNode(), loadTus()]) + .then(([selectedNode, tus]) => { if (!selectedNode) { reject(new Error('No node available')) return } + if (ac.signal.aborted) { + reject(new Error('Upload aborted')) + return + } // In Node, tus-js-client accepts Buffer or fs.ReadStream as the // source. When the caller passes a NodeFile wrapper // (`{ buffer, name, type }`) — which is what the zod diff --git a/packages/sdk/src/sdk/types/File.ts b/packages/sdk/src/sdk/types/File.ts index 0c25408f9b1..864070d9967 100644 --- a/packages/sdk/src/sdk/types/File.ts +++ b/packages/sdk/src/sdk/types/File.ts @@ -1,4 +1,3 @@ -import * as fileType from 'file-type' import { z } from 'zod' /** @@ -64,6 +63,17 @@ const getFileType = async (file: CrossPlatformFile) => { return { mime: file.type } } + if (isNodeFile(file)) { + if (file.type) { + return { mime: file.type } + } + // Loaded on demand so browser and native bundles don't evaluate it + const fileTypeNode: any = await import('file-type') + const { fromBuffer } = + 'fromBuffer' in fileTypeNode ? fileTypeNode : fileTypeNode.default + return await fromBuffer(file.buffer) + } + let fileTypeBrowser: any if (typeof window !== 'undefined' && window) { fileTypeBrowser = await import('file-type/browser') @@ -72,13 +82,7 @@ const getFileType = async (file: CrossPlatformFile) => { } } - if (isNodeFile(file)) { - return file.type - ? { mime: file.type } - : await fileType.fromBuffer(file.buffer) - } else { - return await fileTypeBrowser.fromBlob(file) - } + return await fileTypeBrowser.fromBlob(file) } export const ImageFile = CrossPlatformFileSchema.refine( From eb33dc38208a42880b92c466e958b206d24eec04 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:03:24 -0700 Subject: [PATCH 07/12] perf(sdk): fetch the storage node list on first use StorageNodeSelector hit /health_check in its constructor, so every app boot made the request even though only uploads use the result. Fetch it the first time a node is selected, sharing one request across concurrent callers. Co-Authored-By: Claude Opus 5.5 --- .changeset/sdk-lazy-upload-deps.md | 5 +++ .../StorageNodeSelector.test.ts | 36 +++++++++++++++++++ .../StorageNodeSelector.ts | 31 ++++++++++++++-- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/sdk-lazy-upload-deps.md diff --git a/.changeset/sdk-lazy-upload-deps.md b/.changeset/sdk-lazy-upload-deps.md new file mode 100644 index 00000000000..0d08fe5eeb0 --- /dev/null +++ b/.changeset/sdk-lazy-upload-deps.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': patch +--- + +Load `tus-js-client` and the Node `file-type` path only when an upload or file type check needs them, and fetch the storage node list on first use instead of when the SDK is created. diff --git a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.test.ts b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.test.ts index abd1a2ebad8..744bb57c539 100644 --- a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.test.ts +++ b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.test.ts @@ -143,4 +143,40 @@ describe('StorageNodeSelector', () => { expect(await storageNodeSelector.getSelectedNode()).toBe(null) expect(await storageNodeSelector.triedSelectingAllNodes()).toBe(true) }) + + it('fetches the node list once, on first selection', async () => { + let healthCheckCalls = 0 + const storageNodeC = { + endpoint: 'https://node-c.audius.co', + delegateOwnerWallet: '0xc0ffee254729296a45a3885639AC7E10F9d54973' + } + server.use( + rest.get(`${discoveryNode}/health_check`, (_req, res, ctx) => { + healthCheckCalls++ + const data: HealthCheckResponseData = { + network: { content_nodes: [storageNodeC] } + } + return res(ctx.status(200), ctx.json({ data })) + }), + rest.get(`${storageNodeC.endpoint}/health_check`, (_req, res, ctx) => { + return res(ctx.status(200), ctx.json({ data: { diskHasSpace: true } })) + }) + ) + + const storageNodeSelector = new StorageNodeSelector({ + bootstrapNodes: [storageNodeA, storageNodeB], + logger, + endpoint: discoveryNode + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(healthCheckCalls).toBe(0) + + const [first, second] = await Promise.all([ + storageNodeSelector.getSelectedNode(), + storageNodeSelector.getSelectedNode() + ]) + expect(first).toEqual(storageNodeC.endpoint) + expect(second).toEqual(storageNodeC.endpoint) + expect(healthCheckCalls).toBe(1) + }) }) diff --git a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts index 5ba98f96af2..d8ee51fa370 100644 --- a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts +++ b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts @@ -20,6 +20,7 @@ export class StorageNodeSelector implements StorageNodeSelectorService { private orderedNodes?: string[] // endpoints (lowercase) private selectedNode?: string | null private selectionState: 'healthy_only' | 'failed_all' + private nodesPromise?: Promise constructor(config: StorageNodeSelectorConfig) { this.config = mergeConfigWithDefaults( @@ -32,8 +33,28 @@ export class StorageNodeSelector implements StorageNodeSelectorService { ) this.nodes = this.config.bootstrapNodes ?? [] this.selectionState = 'healthy_only' + } - this.updateAvailableStorageNodes(this.config.endpoint) + /** + * Fetches the node list once, on first use rather than at construction, + * so clients that never upload don't pay for the request. Concurrent + * callers share the same request; a failed request is retried next time. + */ + private ensureAvailableStorageNodes() { + if (!this.nodesPromise) { + this.nodesPromise = this.updateAvailableStorageNodes( + this.config.endpoint + ).then( + (ok) => { + if (!ok) this.nodesPromise = undefined + }, + (e) => { + this.logger.warn('Failed to update available storage nodes', e) + this.nodesPromise = undefined + } + ) + } + return this.nodesPromise } private async updateAvailableStorageNodes(endpoint: string) { @@ -42,7 +63,7 @@ export class StorageNodeSelector implements StorageNodeSelectorService { const healthCheckResponse = await fetch(healthCheckEndpoint) if (!healthCheckResponse.ok) { this.logger.warn('API health check did not respond successfully') - return + return false } const responseData: { data: HealthCheckResponseData } = @@ -53,11 +74,12 @@ export class StorageNodeSelector implements StorageNodeSelectorService { this.logger.warn( 'API health check did not contain any available content nodes' ) - return + return false } this.nodes = contentNodes this.selectionState = 'healthy_only' + return true } public async getSelectedNode(forceReselect = false) { @@ -65,6 +87,7 @@ export class StorageNodeSelector implements StorageNodeSelectorService { return this.selectedNode } + await this.ensureAvailableStorageNodes() return await this.select() } @@ -73,6 +96,8 @@ export class StorageNodeSelector implements StorageNodeSelectorService { } public getNodes(cid: string) { + // Synchronous, so this uses the current list and refreshes it for later + this.ensureAvailableStorageNodes() return this.orderNodes(cid) } From b0c925617bdf500994e933e38ccc6810b7104b50 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:04:58 -0700 Subject: [PATCH 08/12] perf(web): only fetch the visible trending lineup Trending fired week, month, all-time and underground queries on load while showing one of them. Fetch the visible lineup, and start the others when their filter is hovered (desktop) or pressed (mobile web) so switching stays quick. Co-Authored-By: Claude Opus 5.5 --- .../desktop/TrendingPageContent.tsx | 78 ++++++++++--------- .../components/mobile/TrendingPageContent.tsx | 62 +++++++++------ .../pages/trending-page/useTrendingQueries.ts | 70 +++++++++++++++++ 3 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 packages/web/src/pages/trending-page/useTrendingQueries.ts diff --git a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx index c9bde3f8136..57f7913ca0d 100644 --- a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx +++ b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx @@ -4,8 +4,6 @@ import { getTrendingQueryKey, getTrendingUndergroundQueryKey, TRENDING_INITIAL_PAGE_SIZE, - useTrending, - useTrendingUnderground, usePopularGenres } from '@audius/common/api' import { TimeRange } from '@audius/common/models' @@ -32,6 +30,7 @@ import Page from 'components/page/Page' import { useIsContainerNarrow } from 'hooks/useIsContainerNarrow' import { WinnersView } from 'pages/trending-page/components/desktop/WinnersView' import { TRENDING_MESSAGES } from 'pages/trending-page/constants' +import { useTrendingQueries } from 'pages/trending-page/useTrendingQueries' import { updateWinnersWeekParam, isValidWinnersWeek, @@ -119,7 +118,7 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { // user scrolling deep into the lineup. const desktopLoadMorePageSize = 10 - // ----- Three tanquery streams, one per time range ----- + // ----- One tanquery stream per time range; only the visible one fetches const trendingArgs = useMemo( () => ({ initialPageSize: TRENDING_INITIAL_PAGE_SIZE, @@ -128,18 +127,18 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { }), [trendingGenre] ) - const weekQuery = useTrending({ timeRange: TimeRange.WEEK, ...trendingArgs }) - const monthQuery = useTrending({ - timeRange: TimeRange.MONTH, - ...trendingArgs - }) - const allTimeQuery = useTrending({ - timeRange: TimeRange.ALL_TIME, - ...trendingArgs - }) - - const undergroundQuery = useTrendingUnderground({ - pageSize: UNDERGROUND_PAGE_SIZE + const { + weekQuery, + monthQuery, + allTimeQuery, + undergroundQuery, + prefetchTimeRanges, + prefetchUnderground + } = useTrendingQueries({ + trendingArgs, + timeRange: trendingTimeRange, + isUndergroundVisible: category === 'underground', + undergroundPageSize: UNDERGROUND_PAGE_SIZE }) const undergroundQuerySource = useMemo( () => ({ @@ -303,22 +302,26 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => {
{isCondensedBar ? ( - setCategory(value as TrendingCategory)} - options={categoryOptions} - /> + + setCategory(value as TrendingCategory)} + options={categoryOptions} + /> + {category === 'tracks' ? ( <> - + + + { size='large' isSelected={category === 'underground'} onClick={() => setCategory('underground')} + onMouseEnter={prefetchUnderground} /> { {category === 'tracks' ? ( - + + + ({ @@ -293,7 +292,12 @@ const TrendingPageMobileContent = ({ useEffect(() => { setHeader(
- +
) - }, [setHeader, category, handleCategoryChange, categoryOptions]) + }, [ + setHeader, + category, + handleCategoryChange, + categoryOptions, + prefetchUnderground + ]) const content = category === 'tracks' ? ( @@ -350,14 +360,16 @@ const TrendingPageMobileContent = ({ <> {category === 'tracks' ? (
- handleTimeRangeChange(value as TimeRange)} - options={timeRangeOptions} - size='small' - /> + + handleTimeRangeChange(value as TimeRange)} + options={timeRangeOptions} + size='small' + /> + + timeRange: TimeRange + isUndergroundVisible: boolean + undergroundPageSize: number +} + +/** + * Trending lineups for each time range plus underground. Only the visible + * lineup fetches; the others start when prefetched (e.g. on hover of their + * filter) or when they become visible. + */ +export const useTrendingQueries = ({ + trendingArgs, + timeRange, + isUndergroundVisible, + undergroundPageSize +}: UseTrendingQueriesArgs) => { + const [shouldPrefetchTimeRanges, setShouldPrefetchTimeRanges] = + useState(false) + const [shouldPrefetchUnderground, setShouldPrefetchUnderground] = + useState(false) + + const isTimeRangeEnabled = (tr: TimeRange) => + tr === timeRange || shouldPrefetchTimeRanges + + const weekQuery = useTrending( + { timeRange: TimeRange.WEEK, ...trendingArgs }, + { enabled: isTimeRangeEnabled(TimeRange.WEEK) } + ) + const monthQuery = useTrending( + { timeRange: TimeRange.MONTH, ...trendingArgs }, + { enabled: isTimeRangeEnabled(TimeRange.MONTH) } + ) + const allTimeQuery = useTrending( + { timeRange: TimeRange.ALL_TIME, ...trendingArgs }, + { enabled: isTimeRangeEnabled(TimeRange.ALL_TIME) } + ) + const undergroundQuery = useTrendingUnderground( + { pageSize: undergroundPageSize }, + { enabled: isUndergroundVisible || shouldPrefetchUnderground } + ) + + const prefetchTimeRanges = useCallback( + () => setShouldPrefetchTimeRanges(true), + [] + ) + const prefetchUnderground = useCallback( + () => setShouldPrefetchUnderground(true), + [] + ) + + return { + weekQuery, + monthQuery, + allTimeQuery, + undergroundQuery, + prefetchTimeRanges, + prefetchUnderground + } +} From a3b5b5e0f6019a94c5eb32fc63a16208b3bf32cb Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:07:15 -0700 Subject: [PATCH 09/12] perf(web): register the remaining NiceModals lazily Convert the non-crypto NiceModal registrations (share, add to collection, confirmations, contest, welcome, chat blast, etc.) to registerLazyModal so none of them load until opened. Registration moves out of the modal modules so the lazy import can't re-register an open modal. Co-Authored-By: Claude Opus 5.5 --- packages/web/src/app/registerNiceModals.ts | 191 +++++++++++++++--- .../desktop/AddToCollectionModal.tsx | 4 - .../AlbumTrackRemoveConfirmationModal.tsx | 7 - .../artist-pick-modal/ArtistPickModal.tsx | 4 - .../DeletePlaylistConfirmationModal.tsx | 7 - .../DeleteTrackConfirmationModal.tsx | 4 - .../DownloadTrackArchiveModal.tsx | 4 - .../DuplicateAddConfirmationModal.tsx | 4 - .../EarlyReleaseConfirmationModal.tsx | 4 - .../EditAccessConfirmationModal.tsx | 4 - .../FinalizeWinnersConfirmationModal.tsx | 7 - .../HideContentConfirmationModal.tsx | 4 - .../HostRemixContestModal.tsx | 4 - .../InboxUnavailableModal.tsx | 4 - .../LeavingAudiusModal.tsx | 4 - .../PublishConfirmationModal.tsx | 4 - .../PublishHiddenTracksConfirmationModal.tsx | 7 - .../ReplaceTrackConfirmationModal.tsx | 4 - .../ReplaceTrackProgressModal.tsx | 4 - .../src/components/rewards/modals/TopAPI.tsx | 4 - .../src/components/share-modal/ShareModal.tsx | 8 - .../UploadConfirmationModal.tsx | 4 - .../user-badges/TierExplainerModal.tsx | 4 - .../WaitForDownloadModal.tsx | 4 - .../components/welcome-modal/WelcomeModal.tsx | 4 - .../chat-page/components/ChatBlastModal.tsx | 4 - 26 files changed, 162 insertions(+), 145 deletions(-) diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index dda178929a4..3c7ad789c24 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -1,9 +1,9 @@ /** - * Side-effect imports for nice-modal-react registrations. + * nice-modal-react registrations. * - * Each imported module calls `NiceModal.register(id, Component)` and - * `registerNiceModalId(id)` at module scope. Importing this file from - * `AppProviders` ensures all registered modals are available before + * Modals are registered here with `registerLazyModal(id, loader)`, which + * calls `NiceModal.register` and `registerNiceModalId`. Importing this file + * from `AppProviders` ensures all registered modals are available before * anything tries to `showNiceModal(id)`. * * Add new NiceModal-managed modals here as they migrate. @@ -14,42 +14,17 @@ import { registerNiceModalId } from '@audius/common/services' import NiceModal from '@ebay/nice-modal-react' import 'components/add-cash-modal/AddCashModal' -import 'components/add-to-collection/desktop/AddToCollectionModal' -import 'components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal' -import 'components/artist-pick-modal/ArtistPickModal' -import 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' -import 'components/delete-track-confirmation-modal/DeleteTrackConfirmationModal' -import 'components/download-track-archive-modal/DownloadTrackArchiveModal' -import 'components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal' -import 'components/early-release-confirmation-modal/EarlyReleaseConfirmationModal' -import 'components/edit-access-confirmation-modal/EditAccessConfirmationModal' -import 'components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal' -import 'components/hide-confirmation-modal/HideContentConfirmationModal' -import 'components/host-remix-contest-modal/HostRemixContestModal' -import 'components/inbox-unavailable-modal/InboxUnavailableModal' -import 'components/leaving-audius-modal/LeavingAudiusModal' import 'components/locked-content-modal/LockedContentModal' import 'components/payout-wallet-modal/PayoutWalletModal' import 'components/premium-content-purchase-modal/PremiumContentPurchaseModal' -import 'components/publish-confirmation-modal/PublishConfirmationModal' -import 'components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal' import 'components/receive-tokens-modal/ReceiveTokensModal' -import 'components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal' -import 'components/replace-track-progress-modal/ReplaceTrackProgressModal' import 'components/rewards/modals/ClaimAllRewardsModal' -import 'components/rewards/modals/TopAPI' -import 'components/share-modal/ShareModal' import 'components/transaction-details-modal/TransactionDetailsModal' -import 'components/upload-confirmation-modal/UploadConfirmationModal' import 'components/usdc-purchase-details-modal/USDCPurchaseDetailsModal' import 'components/usdc-transaction-details-modal/USDCTransactionDetailsModal' -import 'components/user-badges/TierExplainerModal' -import 'components/wait-for-download-modal/WaitForDownloadModal' -import 'components/welcome-modal/WelcomeModal' import 'components/withdraw-usdc-modal/WithdrawUSDCModal' import 'pages/audio-page/components/modals/AudioBreakdownModal' import 'pages/audio-page/components/modals/TransferAudioMobileDrawer' -import 'pages/chat-page/components/ChatBlastModal' import 'pages/fan-club-detail-page/components/ClaimVestedCoinsModal' import 'pages/rewards-page/components/modals/ChallengeRewardsModal/ChallengeRewardsModal' @@ -96,3 +71,161 @@ registerLazyModal('CoinflowWithdraw', () => 'components/withdraw-usdc-modal/components/CoinflowWithdrawModal' ).then((m) => ({ default: m.CoinflowWithdrawModal })) ) + +// Remaining modals, also lazy so none of them load until opened +registerLazyModal( + 'AddToCollection', + () => import('components/add-to-collection/desktop/AddToCollectionModal') +) +registerLazyModal('AlbumTrackRemoveConfirmation', () => + import( + 'components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal' + ).then((m) => ({ + default: m.AlbumTrackRemoveConfirmationModal + })) +) +registerLazyModal('ArtistPick', () => + import('components/artist-pick-modal/ArtistPickModal').then((m) => ({ + default: m.ArtistPickModal + })) +) +registerLazyModal( + 'DeletePlaylistConfirmation', + () => + import( + 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' + ) +) +registerLazyModal('DeleteTrackConfirmation', () => + import( + 'components/delete-track-confirmation-modal/DeleteTrackConfirmationModal' + ).then((m) => ({ + default: m.DeleteTrackConfirmationModal + })) +) +registerLazyModal('DownloadTrackArchive', () => + import( + 'components/download-track-archive-modal/DownloadTrackArchiveModal' + ).then((m) => ({ + default: m.DownloadTrackArchiveModal + })) +) +registerLazyModal('DuplicateAddConfirmation', () => + import( + 'components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal' + ).then((m) => ({ + default: m.DuplicateAddConfirmationModal + })) +) +registerLazyModal('EarlyReleaseConfirmation', () => + import( + 'components/early-release-confirmation-modal/EarlyReleaseConfirmationModal' + ).then((m) => ({ + default: m.EarlyReleaseConfirmationModal + })) +) +registerLazyModal('EditAccessConfirmation', () => + import( + 'components/edit-access-confirmation-modal/EditAccessConfirmationModal' + ).then((m) => ({ + default: m.EditAccessConfirmationModal + })) +) +registerLazyModal('FinalizeWinnersConfirmation', () => + import( + 'components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal' + ).then((m) => ({ + default: m.FinalizeWinnersConfirmationModal + })) +) +registerLazyModal('HideContentConfirmation', () => + import( + 'components/hide-confirmation-modal/HideContentConfirmationModal' + ).then((m) => ({ + default: m.HideContentConfirmationModal + })) +) +registerLazyModal('HostRemixContest', () => + import('components/host-remix-contest-modal/HostRemixContestModal').then( + (m) => ({ + default: m.HostRemixContestModal + }) + ) +) +registerLazyModal('InboxUnavailableModal', () => + import('components/inbox-unavailable-modal/InboxUnavailableModal').then( + (m) => ({ + default: m.InboxUnavailableModal + }) + ) +) +registerLazyModal('LeavingAudiusModal', () => + import('components/leaving-audius-modal/LeavingAudiusModal').then((m) => ({ + default: m.LeavingAudiusModal + })) +) +registerLazyModal('PublishConfirmation', () => + import('components/publish-confirmation-modal/PublishConfirmationModal').then( + (m) => ({ + default: m.PublishConfirmationModal + }) + ) +) +registerLazyModal('PublishHiddenTracksConfirmation', () => + import( + 'components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal' + ).then((m) => ({ + default: m.PublishHiddenTracksConfirmationModal + })) +) +registerLazyModal('ReplaceTrackConfirmation', () => + import( + 'components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal' + ).then((m) => ({ + default: m.ReplaceTrackConfirmationModal + })) +) +registerLazyModal('ReplaceTrackProgress', () => + import( + 'components/replace-track-progress-modal/ReplaceTrackProgressModal' + ).then((m) => ({ + default: m.ReplaceTrackProgressModal + })) +) +registerLazyModal( + 'APIRewardsExplainer', + () => import('components/rewards/modals/TopAPI') +) +registerLazyModal('Share', () => + import('components/share-modal/ShareModal').then((m) => ({ + default: m.ShareModal + })) +) +registerLazyModal('UploadConfirmation', () => + import('components/upload-confirmation-modal/UploadConfirmationModal').then( + (m) => ({ + default: m.UploadConfirmationModal + }) + ) +) +registerLazyModal( + 'TiersExplainer', + () => import('components/user-badges/TierExplainerModal') +) +registerLazyModal('WaitForDownloadModal', () => + import('components/wait-for-download-modal/WaitForDownloadModal').then( + (m) => ({ + default: m.WaitForDownloadModal + }) + ) +) +registerLazyModal('Welcome', () => + import('components/welcome-modal/WelcomeModal').then((m) => ({ + default: m.WelcomeModal + })) +) +registerLazyModal('ChatBlastModal', () => + import('pages/chat-page/components/ChatBlastModal').then((m) => ({ + default: m.ChatBlastModal + })) +) diff --git a/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx b/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx index 4de6c789200..863ef1fb76d 100644 --- a/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx +++ b/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx @@ -12,7 +12,6 @@ import { SquareSizes, Collection } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { cacheCollectionsActions, addToCollectionUISelectors, @@ -248,7 +247,4 @@ const CollectionItem = ({ ) } -NiceModal.register('AddToCollection', AddToCollectionModal) -registerNiceModalId('AddToCollection') - export default AddToCollectionModal diff --git a/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx b/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx index fe932682f48..029a54803a5 100644 --- a/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx +++ b/packages/web/src/components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useAlbumTrackRemoveConfirmationModal, cacheCollectionsActions @@ -71,9 +70,3 @@ export const AlbumTrackRemoveConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register( - 'AlbumTrackRemoveConfirmation', - AlbumTrackRemoveConfirmationModal -) -registerNiceModalId('AlbumTrackRemoveConfirmation') diff --git a/packages/web/src/components/artist-pick-modal/ArtistPickModal.tsx b/packages/web/src/components/artist-pick-modal/ArtistPickModal.tsx index 667abcbf99f..0e09ce6411c 100644 --- a/packages/web/src/components/artist-pick-modal/ArtistPickModal.tsx +++ b/packages/web/src/components/artist-pick-modal/ArtistPickModal.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { useCurrentAccountUser } from '@audius/common/api' -import { registerNiceModalId } from '@audius/common/services' import { tracksSocialActions, useArtistPickModal } from '@audius/common/store' import { Button, @@ -87,6 +86,3 @@ export const ArtistPickModal = NiceModal.create(() => { ) }) - -NiceModal.register('ArtistPick', ArtistPickModal) -registerNiceModalId('ArtistPick') diff --git a/packages/web/src/components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal.tsx b/packages/web/src/components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal.tsx index 785ab068675..fa439e0d0aa 100644 --- a/packages/web/src/components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal.tsx +++ b/packages/web/src/components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal.tsx @@ -1,7 +1,6 @@ import { useCallback, useContext, useMemo } from 'react' import { useDeleteCollection } from '@audius/common/api' -import { registerNiceModalId } from '@audius/common/services' import { deletePlaylistConfirmationModalUISelectors } from '@audius/common/store' import { route } from '@audius/common/utils' import NiceModal, { useModal } from '@ebay/nice-modal-react' @@ -67,10 +66,4 @@ const DeletePlaylistConfirmationModal = NiceModal.create(() => { ) }) -NiceModal.register( - 'DeletePlaylistConfirmation', - DeletePlaylistConfirmationModal -) -registerNiceModalId('DeletePlaylistConfirmation') - export default DeletePlaylistConfirmationModal diff --git a/packages/web/src/components/delete-track-confirmation-modal/DeleteTrackConfirmationModal.tsx b/packages/web/src/components/delete-track-confirmation-modal/DeleteTrackConfirmationModal.tsx index fd895744e35..51fb10d4b0d 100644 --- a/packages/web/src/components/delete-track-confirmation-modal/DeleteTrackConfirmationModal.tsx +++ b/packages/web/src/components/delete-track-confirmation-modal/DeleteTrackConfirmationModal.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { useDeleteTrack } from '@audius/common/api' -import { registerNiceModalId } from '@audius/common/services' import { useDeleteTrackConfirmationModal } from '@audius/common/store' import NiceModal, { useModal } from '@ebay/nice-modal-react' @@ -38,6 +37,3 @@ export const DeleteTrackConfirmationModal = NiceModal.create(() => { /> ) }) - -NiceModal.register('DeleteTrackConfirmation', DeleteTrackConfirmationModal) -registerNiceModalId('DeleteTrackConfirmation') diff --git a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx b/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx index 594a58c6ecb..8a9ef6e7507 100644 --- a/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx +++ b/packages/web/src/components/download-track-archive-modal/DownloadTrackArchiveModal.tsx @@ -6,7 +6,6 @@ import { useGetStemsArchiveJobStatus } from '@audius/common/api' import { ID } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { useDownloadTrackArchiveModal } from '@audius/common/store' import { Modal, @@ -173,6 +172,3 @@ export const DownloadTrackArchiveModal = NiceModal.create(() => { /> ) }) - -NiceModal.register('DownloadTrackArchive', DownloadTrackArchiveModal) -registerNiceModalId('DownloadTrackArchive') diff --git a/packages/web/src/components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal.tsx b/packages/web/src/components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal.tsx index 2b94b83878c..ca1f16146fc 100644 --- a/packages/web/src/components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal.tsx +++ b/packages/web/src/components/duplicate-add-confirmation-modal/DuplicateAddConfirmationModal.tsx @@ -1,7 +1,6 @@ import { useCallback, useContext } from 'react' import { useCurrentAccountUser, useCollection } from '@audius/common/api' -import { registerNiceModalId } from '@audius/common/services' import { cacheCollectionsActions, duplicateAddConfirmationModalUISelectors @@ -118,6 +117,3 @@ export const DuplicateAddConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('DuplicateAddConfirmation', DuplicateAddConfirmationModal) -registerNiceModalId('DuplicateAddConfirmation') diff --git a/packages/web/src/components/early-release-confirmation-modal/EarlyReleaseConfirmationModal.tsx b/packages/web/src/components/early-release-confirmation-modal/EarlyReleaseConfirmationModal.tsx index 8924c4b0c68..16a4a9a3e3b 100644 --- a/packages/web/src/components/early-release-confirmation-modal/EarlyReleaseConfirmationModal.tsx +++ b/packages/web/src/components/early-release-confirmation-modal/EarlyReleaseConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useEarlyReleaseConfirmationModal } from '@audius/common/store' import { Modal, @@ -70,6 +69,3 @@ export const EarlyReleaseConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('EarlyReleaseConfirmation', EarlyReleaseConfirmationModal) -registerNiceModalId('EarlyReleaseConfirmation') diff --git a/packages/web/src/components/edit-access-confirmation-modal/EditAccessConfirmationModal.tsx b/packages/web/src/components/edit-access-confirmation-modal/EditAccessConfirmationModal.tsx index b9fdc2276c5..f3e1d63498d 100644 --- a/packages/web/src/components/edit-access-confirmation-modal/EditAccessConfirmationModal.tsx +++ b/packages/web/src/components/edit-access-confirmation-modal/EditAccessConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useEditAccessConfirmationModal } from '@audius/common/store' import { Modal, @@ -67,6 +66,3 @@ export const EditAccessConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('EditAccessConfirmation', EditAccessConfirmationModal) -registerNiceModalId('EditAccessConfirmation') diff --git a/packages/web/src/components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal.tsx b/packages/web/src/components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal.tsx index 1580edc3e05..bfce06a8c91 100644 --- a/packages/web/src/components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal.tsx +++ b/packages/web/src/components/finalize-winners-confirmation-modal/FinalizeWinnersConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useFinalizeWinnersConfirmationModal } from '@audius/common/store' import { Modal, @@ -68,9 +67,3 @@ export const FinalizeWinnersConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register( - 'FinalizeWinnersConfirmation', - FinalizeWinnersConfirmationModal -) -registerNiceModalId('FinalizeWinnersConfirmation') diff --git a/packages/web/src/components/hide-confirmation-modal/HideContentConfirmationModal.tsx b/packages/web/src/components/hide-confirmation-modal/HideContentConfirmationModal.tsx index c1b62de0b6b..34eb717e431 100644 --- a/packages/web/src/components/hide-confirmation-modal/HideContentConfirmationModal.tsx +++ b/packages/web/src/components/hide-confirmation-modal/HideContentConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useHideContentConfirmationModal } from '@audius/common/store' import { Modal, @@ -67,6 +66,3 @@ export const HideContentConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('HideContentConfirmation', HideContentConfirmationModal) -registerNiceModalId('HideContentConfirmation') diff --git a/packages/web/src/components/host-remix-contest-modal/HostRemixContestModal.tsx b/packages/web/src/components/host-remix-contest-modal/HostRemixContestModal.tsx index a3e5a735bc9..8d460a384d8 100644 --- a/packages/web/src/components/host-remix-contest-modal/HostRemixContestModal.tsx +++ b/packages/web/src/components/host-remix-contest-modal/HostRemixContestModal.tsx @@ -9,7 +9,6 @@ import { useUpdateEvent } from '@audius/common/api' import { remixMessages } from '@audius/common/messages' -import { registerNiceModalId } from '@audius/common/services' import { useHostRemixContestModal } from '@audius/common/store' import { dayjs } from '@audius/common/utils' import { @@ -314,6 +313,3 @@ export const HostRemixContestModal = NiceModal.create(() => { ) }) - -NiceModal.register('HostRemixContest', HostRemixContestModal) -registerNiceModalId('HostRemixContest') diff --git a/packages/web/src/components/inbox-unavailable-modal/InboxUnavailableModal.tsx b/packages/web/src/components/inbox-unavailable-modal/InboxUnavailableModal.tsx index d9a41584c7d..81c64cda04d 100644 --- a/packages/web/src/components/inbox-unavailable-modal/InboxUnavailableModal.tsx +++ b/packages/web/src/components/inbox-unavailable-modal/InboxUnavailableModal.tsx @@ -2,7 +2,6 @@ import { ReactNode, useCallback } from 'react' import { useCurrentUserId, useUser } from '@audius/common/api' import { FollowSource, User } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { chatActions, chatSelectors, @@ -182,6 +181,3 @@ export const InboxUnavailableModal = NiceModal.create(() => { ) }) - -NiceModal.register('InboxUnavailableModal', InboxUnavailableModal) -registerNiceModalId('InboxUnavailableModal') diff --git a/packages/web/src/components/leaving-audius-modal/LeavingAudiusModal.tsx b/packages/web/src/components/leaving-audius-modal/LeavingAudiusModal.tsx index 2b15cec679e..75af6b40a0f 100644 --- a/packages/web/src/components/leaving-audius-modal/LeavingAudiusModal.tsx +++ b/packages/web/src/components/leaving-audius-modal/LeavingAudiusModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useLeavingAudiusModal } from '@audius/common/store' import { Modal, @@ -73,6 +72,3 @@ export const LeavingAudiusModal = NiceModal.create(() => { ) }) - -NiceModal.register('LeavingAudiusModal', LeavingAudiusModal) -registerNiceModalId('LeavingAudiusModal') diff --git a/packages/web/src/components/publish-confirmation-modal/PublishConfirmationModal.tsx b/packages/web/src/components/publish-confirmation-modal/PublishConfirmationModal.tsx index 3df27a78446..b64fbaf1b26 100644 --- a/packages/web/src/components/publish-confirmation-modal/PublishConfirmationModal.tsx +++ b/packages/web/src/components/publish-confirmation-modal/PublishConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { usePublishConfirmationModal } from '@audius/common/store' import { Button, @@ -58,6 +57,3 @@ export const PublishConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('PublishConfirmation', PublishConfirmationModal) -registerNiceModalId('PublishConfirmation') diff --git a/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx b/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx index f94efb7bf98..86e3c59b669 100644 --- a/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx +++ b/packages/web/src/components/publish-hidden-tracks-confirmation-modal/PublishHiddenTracksConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { keepHiddenTracksPrivate, publishHiddenTracksConfirmed, @@ -78,9 +77,3 @@ export const PublishHiddenTracksConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register( - 'PublishHiddenTracksConfirmation', - PublishHiddenTracksConfirmationModal -) -registerNiceModalId('PublishHiddenTracksConfirmation') diff --git a/packages/web/src/components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal.tsx b/packages/web/src/components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal.tsx index 70de3dcef27..3bbbea48c8d 100644 --- a/packages/web/src/components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal.tsx +++ b/packages/web/src/components/replace-track-confirmation-modal/ReplaceTrackConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useReplaceTrackConfirmationModal } from '@audius/common/store' import { Modal, @@ -73,6 +72,3 @@ export const ReplaceTrackConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('ReplaceTrackConfirmation', ReplaceTrackConfirmationModal) -registerNiceModalId('ReplaceTrackConfirmation') diff --git a/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx b/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx index 6002e46c1f2..4d9886c95b9 100644 --- a/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx +++ b/packages/web/src/components/replace-track-progress-modal/ReplaceTrackProgressModal.tsx @@ -1,4 +1,3 @@ -import { registerNiceModalId } from '@audius/common/services' import { useReplaceTrackProgressModal } from '@audius/common/store' import { Modal, @@ -84,6 +83,3 @@ export const ReplaceTrackProgressModal = NiceModal.create(() => { ) }) - -NiceModal.register('ReplaceTrackProgress', ReplaceTrackProgressModal) -registerNiceModalId('ReplaceTrackProgress') diff --git a/packages/web/src/components/rewards/modals/TopAPI.tsx b/packages/web/src/components/rewards/modals/TopAPI.tsx index 1de77edfa2d..b9060ed274f 100644 --- a/packages/web/src/components/rewards/modals/TopAPI.tsx +++ b/packages/web/src/components/rewards/modals/TopAPI.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { route } from '@audius/common/utils' import { Button, IconArrowRight as IconArrow } from '@audius/harmony' import NiceModal, { useModal } from '@ebay/nice-modal-react' @@ -60,7 +59,4 @@ const TopAPIModal = NiceModal.create(() => { ) }) -NiceModal.register('APIRewardsExplainer', TopAPIModal) -registerNiceModalId('APIRewardsExplainer') - export default TopAPIModal diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index 52ec901c7cb..7b23980360e 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -7,7 +7,6 @@ import { useShareContent } from '@audius/common/hooks' import { Name, PlayableType } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { collectionsSocialActions, tracksSocialActions, @@ -189,10 +188,3 @@ export const ShareModal = NiceModal.create(() => { if (isMobile) return return }) - -// Register the modal so saga code (and anything else outside React) can -// open it via `showNiceModal('Share')`. The id is also added to the -// nice-modal bridge allowlist so legacy `setVisibility('Share', true)` -// dispatches translate to `showNiceModal('Share')`. -NiceModal.register('Share', ShareModal) -registerNiceModalId('Share') diff --git a/packages/web/src/components/upload-confirmation-modal/UploadConfirmationModal.tsx b/packages/web/src/components/upload-confirmation-modal/UploadConfirmationModal.tsx index b08d931e408..5f5a502dddc 100644 --- a/packages/web/src/components/upload-confirmation-modal/UploadConfirmationModal.tsx +++ b/packages/web/src/components/upload-confirmation-modal/UploadConfirmationModal.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { registerNiceModalId } from '@audius/common/services' import { useUploadConfirmationModal } from '@audius/common/store' import { Modal, @@ -63,6 +62,3 @@ export const UploadConfirmationModal = NiceModal.create(() => { ) }) - -NiceModal.register('UploadConfirmation', UploadConfirmationModal) -registerNiceModalId('UploadConfirmation') diff --git a/packages/web/src/components/user-badges/TierExplainerModal.tsx b/packages/web/src/components/user-badges/TierExplainerModal.tsx index afa4bef8f49..ea355a2d480 100644 --- a/packages/web/src/components/user-badges/TierExplainerModal.tsx +++ b/packages/web/src/components/user-badges/TierExplainerModal.tsx @@ -1,7 +1,6 @@ import { useCallback, ReactElement } from 'react' import { BadgeTier } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { route, Nullable } from '@audius/common/utils' import { Button, @@ -149,7 +148,4 @@ const TierExplainerModal = NiceModal.create(() => { ) }) -NiceModal.register('TiersExplainer', TierExplainerModal) -registerNiceModalId('TiersExplainer') - export default TierExplainerModal diff --git a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx index fd826003d1d..0c84a6ba0c2 100644 --- a/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx +++ b/packages/web/src/components/wait-for-download-modal/WaitForDownloadModal.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect } from 'react' import { useTrack, useUser } from '@audius/common/api' import { DownloadQuality } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { useWaitForDownloadModal, tracksSocialActions, @@ -123,6 +122,3 @@ export const WaitForDownloadModal = NiceModal.create(() => { ) }) - -NiceModal.register('WaitForDownloadModal', WaitForDownloadModal) -registerNiceModalId('WaitForDownloadModal') diff --git a/packages/web/src/components/welcome-modal/WelcomeModal.tsx b/packages/web/src/components/welcome-modal/WelcomeModal.tsx index 451620e262b..4601690e3ce 100644 --- a/packages/web/src/components/welcome-modal/WelcomeModal.tsx +++ b/packages/web/src/components/welcome-modal/WelcomeModal.tsx @@ -4,7 +4,6 @@ import { useCurrentAccountUser } from '@audius/common/api' import { imageProfilePicEmpty } from '@audius/common/assets' import { welcomeModalMessages } from '@audius/common/messages' import { Name, SquareSizes } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { fillString, route } from '@audius/common/utils' import { Modal, @@ -137,6 +136,3 @@ export const WelcomeModal = NiceModal.create(() => { ) }) - -NiceModal.register('Welcome', WelcomeModal) -registerNiceModalId('Welcome') diff --git a/packages/web/src/pages/chat-page/components/ChatBlastModal.tsx b/packages/web/src/pages/chat-page/components/ChatBlastModal.tsx index a45e6cc8a46..c83fdc1e2ef 100644 --- a/packages/web/src/pages/chat-page/components/ChatBlastModal.tsx +++ b/packages/web/src/pages/chat-page/components/ChatBlastModal.tsx @@ -11,7 +11,6 @@ import { usePurchasersAudience, useRemixersAudience } from '@audius/common/hooks' -import { registerNiceModalId } from '@audius/common/services' import { chatActions, useCreateChatModal } from '@audius/common/src/store' import { formatNumberCommas } from '@audius/common/utils' import { @@ -163,9 +162,6 @@ export const ChatBlastModal = NiceModal.create(() => { ) }) -NiceModal.register('ChatBlastModal', ChatBlastModal) -registerNiceModalId('ChatBlastModal') - const ChatBlastsFields = () => { const [field] = useField(TARGET_AUDIENCE_FIELD) From 037c0e5f09e128b751045221f6f7e03d20117c04 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:49:19 -0700 Subject: [PATCH 10/12] perf(web): run svgo on SVG components Web's svgr had no svgo pass, so icons shipped as raw Figma exports. Use the same preset mobile runs on these files, keeping viewBox, ids and colors as they are and skipping the lossy curve and transform rewrites. Sources shrink from 794 KB to 546 KB; a pixel diff of all 279 SVGs at 8x shows only sub-pixel edge changes. Co-Authored-By: Claude Opus 5.5 --- packages/web/vite.config.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 85d62090a6f..abca4ae84bc 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -18,6 +18,35 @@ import svgr from 'vite-plugin-svgr' const SOURCEMAP_URL = 'https://s3.us-west-1.amazonaws.com/sourcemaps.audius.co/' +// Matches what mobile's react-native-svg-transformer runs on the same icons, +// plus id handling that is safe when many SVGs are inlined into one page. +const svgoConfig = { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + // viewBox is required to resize SVGs with CSS + removeViewBox: false, + // Keep colors exact so .svgrrc replaceAttrValues still matches + convertColors: false, + removeUnknownsAndDefaults: false, + // Minified ids collide between inline SVGs on the same page + cleanupIds: { minify: false, remove: false }, + removeUselessDefs: false, + removeTitle: false, + inlineStyles: { onlyMatchedOnce: false }, + // Avoid lossy curve and transform rewrites (visible on logos with + // tiny-scale image transforms) + convertPathData: { convertToQ: false, makeArcs: false }, + convertTransform: false + } + } + } + ] +} + export default defineConfig(async ({ mode }) => { // Despite loading env here, the result is the same as a filtered process.env // rather than dynamically loading the correct env file by mode. @@ -91,6 +120,10 @@ export default defineConfig(async ({ mode }) => { plugins: [ glslify(), svgr({ + svgrOptions: { + plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + svgoConfig + }, include: '**/*.svg', exclude: [ 'src/assets/img/wallet-link.svg', From 079c0e872240db6e9ffbc749563dcc4d799413cc Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 12 Oct 2026 17:51:12 -0700 Subject: [PATCH 11/12] perf(web): preconnect to the API and Optimizely hosts The first API fetches and the Optimizely datafile script both wait on a fresh connection. The API link is crossorigin because fetch uses CORS. Co-Authored-By: Claude Opus 5.5 --- packages/web/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/web/index.html b/packages/web/index.html index 9ee8e99907c..bb92f3be09a 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -3,6 +3,10 @@ + + + + Date: Mon, 12 Oct 2026 17:53:24 -0700 Subject: [PATCH 12/12] perf(web): split framework deps into a vendor chunk React, react-dom, react-router, redux, react-redux, redux-saga and query-core change rarely, so a separate chunk stays cached across deploys. The chunk has no imports of its own and is modulepreloaded from index.html. SSR keeps its default chunking. Co-Authored-By: Claude Opus 5.5 --- packages/web/vite.config.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index abca4ae84bc..801f21a15a0 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -87,7 +87,26 @@ export default defineConfig(async ({ mode }) => { rollupOptions: { output: { sourcemapBaseUrl: - env.VITE_ENV === 'production' ? SOURCEMAP_URL : undefined + env.VITE_ENV === 'production' ? SOURCEMAP_URL : undefined, + // Stable framework deps get their own chunk so repeat visitors keep + // it cached across deploys. Object form pulls each package's own + // dependencies in too, so the chunk never imports back into the + // entry. The SSR build keeps its default chunking. + manualChunks: ssr + ? undefined + : { + vendor: [ + 'react', + 'react/jsx-runtime', + 'react-dom', + 'react-dom/client', + 'react-router', + 'redux', + 'react-redux', + 'redux-saga', + '@tanstack/query-core' + ] + } } } },