From dab37f6a898fad83bda9111da9288d3674fbe959 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 20 Jun 2026 00:03:24 -0400 Subject: [PATCH] fix(ui): load user libraries on app init so NowPlaying filters correctly The NowPlaying panel filters entries by the user's accessible libraries via useSelectedLibraries(). That selector reads userLibraries from the store, but the data was only fetched by LibrarySelector, which is rendered solely when the sidebar is open. On first load with the sidebar closed, userLibraries was empty, so getNowPlaying was called without a musicFolderId and the server returned entries from all libraries until a later refresh. Extract the loading logic into a useUserLibraries hook and call it from Layout, so the libraries are always loaded regardless of sidebar state. LibrarySelector now reuses the same hook instead of duplicating the fetch. Signed-off-by: Deluan --- ui/src/common/LibrarySelector.jsx | 40 +++------------- ui/src/common/index.js | 1 + ui/src/common/useUserLibraries.js | 40 ++++++++++++++++ ui/src/common/useUserLibraries.test.js | 65 ++++++++++++++++++++++++++ ui/src/layout/Layout.jsx | 3 +- 5 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 ui/src/common/useUserLibraries.js create mode 100644 ui/src/common/useUserLibraries.test.js diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx index 1e89d3ec6..170211c7f 100644 --- a/ui/src/common/LibrarySelector.jsx +++ b/ui/src/common/LibrarySelector.jsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useDataProvider, useTranslate, useRefresh } from 'react-admin' +import { useTranslate, useRefresh } from 'react-admin' import { Box, Chip, @@ -15,8 +15,8 @@ import { makeStyles, } from '@material-ui/core' import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons' -import { setSelectedLibraries, setUserLibraries } from '../actions' -import { useRefreshOnEvents } from './useRefreshOnEvents' +import { setSelectedLibraries } from '../actions' +import { useUserLibraries } from './useUserLibraries' const useStyles = makeStyles((theme) => ({ root: { @@ -70,7 +70,6 @@ const useStyles = makeStyles((theme) => ({ const LibrarySelector = () => { const classes = useStyles() const dispatch = useDispatch() - const dataProvider = useDataProvider() const translate = useTranslate() const refresh = useRefresh() const [anchorEl, setAnchorEl] = useState(null) @@ -80,34 +79,9 @@ const LibrarySelector = () => { (state) => state.library, ) - // Load user's libraries when component mounts - const loadUserLibraries = useCallback(async () => { - const userId = localStorage.getItem('userId') - if (userId) { - try { - const { data } = await dataProvider.getOne('user', { id: userId }) - const libraries = data.libraries || [] - dispatch(setUserLibraries(libraries)) - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Could not load user libraries (this may be expected for non-admin users):', - error, - ) - } - } - }, [dataProvider, dispatch]) - - // Initial load - useEffect(() => { - loadUserLibraries() - }, [loadUserLibraries]) - - // Reload user libraries when library changes occur - useRefreshOnEvents({ - events: ['library', 'user'], - onRefresh: loadUserLibraries, - }) + // Keep the user's libraries loaded (also done at the Layout level so the data + // is available even when this selector isn't rendered). + useUserLibraries() // Don't render if user has no libraries or only has one library if (!userLibraries.length || userLibraries.length === 1) { diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..6959ed41b 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -28,6 +28,7 @@ export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' export * from './useRefreshOnEvents' +export * from './useUserLibraries' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' diff --git a/ui/src/common/useUserLibraries.js b/ui/src/common/useUserLibraries.js new file mode 100644 index 000000000..7660ef8c4 --- /dev/null +++ b/ui/src/common/useUserLibraries.js @@ -0,0 +1,40 @@ +import { useCallback, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { useDataProvider } from 'react-admin' +import { setUserLibraries } from '../actions' +import { useRefreshOnEvents } from './useRefreshOnEvents' + +/** + * Loads the current user's accessible libraries into the Redux store and keeps + * them refreshed when library/user events occur. Mount this once high in the + * tree (e.g. the Layout) so consumers like useSelectedLibraries always have the + * data available, regardless of whether the sidebar/LibrarySelector is open. + */ +export const useUserLibraries = () => { + const dispatch = useDispatch() + const dataProvider = useDataProvider() + + const loadUserLibraries = useCallback(async () => { + const userId = localStorage.getItem('userId') + if (!userId) return + try { + const { data } = await dataProvider.getOne('user', { id: userId }) + dispatch(setUserLibraries(data.libraries || [])) + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + 'Could not load user libraries (this may be expected for non-admin users):', + error, + ) + } + }, [dataProvider, dispatch]) + + useEffect(() => { + loadUserLibraries() + }, [loadUserLibraries]) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh: loadUserLibraries, + }) +} diff --git a/ui/src/common/useUserLibraries.test.js b/ui/src/common/useUserLibraries.test.js new file mode 100644 index 000000000..48035e745 --- /dev/null +++ b/ui/src/common/useUserLibraries.test.js @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { useUserLibraries } from './useUserLibraries' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const mockDispatch = vi.fn() +const mockGetOne = vi.fn() + +vi.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, +})) + +vi.mock('react-admin', () => ({ + useDataProvider: () => ({ getOne: mockGetOne }), +})) + +vi.mock('./useRefreshOnEvents', () => ({ + useRefreshOnEvents: vi.fn(), +})) + +describe('useUserLibraries', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('loads the user libraries and dispatches them on mount', async () => { + localStorage.setItem('userId', 'u-1') + const libraries = [{ id: 1 }, { id: 2 }] + mockGetOne.mockResolvedValue({ data: { libraries } }) + + renderHook(() => useUserLibraries()) + await flushPromises() + + expect(mockGetOne).toHaveBeenCalledWith('user', { id: 'u-1' }) + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ data: libraries }), + ) + }) + + it('does not fetch when there is no userId', () => { + renderHook(() => useUserLibraries()) + + expect(mockGetOne).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('handles a failed fetch without dispatching', async () => { + localStorage.setItem('userId', 'u-1') + mockGetOne.mockRejectedValue(new Error('forbidden')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + renderHook(() => useUserLibraries()) + await flushPromises() + + expect(mockGetOne).toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index 44cf9b42c..2648680c7 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -7,7 +7,7 @@ import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' -import { useSearchRefocus } from '../common' +import { useSearchRefocus, useUserLibraries } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -19,6 +19,7 @@ const Layout = (props) => { const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() useSearchRefocus() + useUserLibraries() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),