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/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/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/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) } 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( 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 @@ + + + + '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/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index ade608f982f..52ae4248d86 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' @@ -73,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' @@ -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/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/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} ) 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} ) } 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) 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' +} 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 + } +} 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 }) 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 diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 85d62090a6f..801f21a15a0 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. @@ -58,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' + ] + } } } }, @@ -91,6 +139,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',