feat: add useRefreshOnEvents hook for custom refresh logic on event changes

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-28 12:49:25 -04:00
commit 340192693e
7 changed files with 433 additions and 61 deletions

View file

@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
var _ events.Broker = (*fakeEventBroker)(nil)

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
import {
@ -16,6 +16,7 @@ import {
} from '@material-ui/core'
import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
import { setSelectedLibraries, setUserLibraries } from '../actions'
import { useRefreshOnEvents } from './useRefreshOnEvents'
const useStyles = makeStyles((theme) => ({
root: {
@ -74,76 +75,39 @@ const LibrarySelector = () => {
const refresh = useRefresh()
const [anchorEl, setAnchorEl] = useState(null)
const [open, setOpen] = useState(false)
const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
const { userLibraries, selectedLibraries } = useSelector(
(state) => state.library,
)
// Watch for refresh events to reload libraries
const refreshData = useSelector(
(state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
)
// Load user's libraries when component mounts or when refresh events occur
useEffect(() => {
const loadUserLibraries = async () => {
// Get userId from localStorage (not from identity which returns username)
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,
)
}
// 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,
)
}
}
loadUserLibraries()
}, [dataProvider, dispatch])
// Watch for refresh events and reload libraries when they occur
// Initial load
useEffect(() => {
const { resources, lastReceived } = refreshData
if (lastReceived <= lastRefreshTime) {
return
}
loadUserLibraries()
}, [loadUserLibraries])
const shouldReload = resources &&
(resources['*'] === '*' ||
resources.library ||
resources.user)
if (shouldReload) {
setLastRefreshTime(lastReceived)
const loadUserLibraries = 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 reload user libraries after refresh event:',
error,
)
}
}
}
loadUserLibraries()
}
}, [dataProvider, dispatch, refreshData, lastRefreshTime])
// Reload user libraries when library changes occur
useRefreshOnEvents({
events: ['library'],
onRefresh: loadUserLibraries,
})
// Don't render if user has no libraries or only has one library
if (!userLibraries.length || userLibraries.length === 1) {

View file

@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick'
export * from './useInterval'
export * from './useResourceRefresh'
export * from './useRefreshOnEvents'
export * from './useToggleLove'
export * from './useTraceUpdate'
export * from './Writable'

View file

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
/**
* A reusable hook for triggering custom reload logic when specific SSE events occur.
*
* This hook is ideal when:
* - Your component displays derived/related data that isn't directly managed by react-admin
* - You need custom loading logic that goes beyond simple dataProvider.getMany() calls
* - Your data comes from non-standard endpoints or requires special processing
* - You want to reload parent/related resources when child resources change
*
* @param {Object} options - Configuration options
* @param {Array<string>} options.events - Array of event types to listen for (e.g., ['library', 'user', '*'])
* @param {Function} options.onRefresh - Async function to call when events occur.
* Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders.
*
* @example
* // Example 1: LibrarySelector - Reload user data when library changes
* const loadUserLibraries = useCallback(async () => {
* const userId = localStorage.getItem('userId')
* if (userId) {
* const { data } = await dataProvider.getOne('user', { id: userId })
* dispatch(setUserLibraries(data.libraries || []))
* }
* }, [dataProvider, dispatch])
*
* useRefreshOnEvents({
* events: ['library', 'user'],
* onRefresh: loadUserLibraries
* })
*
* @example
* // Example 2: Statistics Dashboard - Reload stats when any music data changes
* const loadStats = useCallback(async () => {
* const stats = await dataProvider.customRequest('GET', 'stats')
* setDashboardStats(stats)
* }, [dataProvider, setDashboardStats])
*
* useRefreshOnEvents({
* events: ['album', 'song', 'artist'],
* onRefresh: loadStats
* })
*
* @example
* // Example 3: Permission-based UI - Reload permissions when user changes
* const loadPermissions = useCallback(async () => {
* const authData = await authProvider.getPermissions()
* setUserPermissions(authData)
* }, [authProvider, setUserPermissions])
*
* useRefreshOnEvents({
* events: ['user'],
* onRefresh: loadPermissions
* })
*
* @example
* // Example 4: Listen to all events (use sparingly)
* const reloadAll = useCallback(async () => {
* // This will trigger on ANY refresh event
* await reloadEverything()
* }, [reloadEverything])
*
* useRefreshOnEvents({
* events: ['*'],
* onRefresh: reloadAll
* })
*/
export const useRefreshOnEvents = ({ events, onRefresh }) => {
const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
const refreshData = useSelector(
(state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
)
useEffect(() => {
const { resources, lastReceived } = refreshData
// Only process if we have new events
if (lastReceived <= lastRefreshTime) {
return
}
// Check if any of the events we're interested in occurred
const shouldRefresh =
resources &&
// Global refresh event
(resources['*'] === '*' ||
// Check for specific events we're listening to
events.some((eventType) => {
if (eventType === '*') {
return true // Listen to all events
}
return resources[eventType] // Check if this specific event occurred
}))
if (shouldRefresh) {
setLastRefreshTime(lastReceived)
// Call the custom refresh function
if (onRefresh) {
onRefresh().catch((error) => {
// eslint-disable-next-line no-console
console.warn('Error in useRefreshOnEvents onRefresh callback:', error)
})
}
}
}, [refreshData, lastRefreshTime, events, onRefresh])
}

View file

@ -0,0 +1,233 @@
import { vi } from 'vitest'
import * as React from 'react'
import * as Redux from 'react-redux'
import { useRefreshOnEvents } from './useRefreshOnEvents'
vi.mock('react', async () => {
const actual = await vi.importActual('react')
return {
...actual,
useState: vi.fn(),
useEffect: vi.fn(),
}
})
vi.mock('react-redux', async () => {
const actual = await vi.importActual('react-redux')
return {
...actual,
useSelector: vi.fn(),
}
})
describe('useRefreshOnEvents', () => {
const setState = vi.fn()
const useStateMock = (initState) => [initState, setState]
const onRefresh = vi.fn().mockResolvedValue()
let lastTime
let mockUseEffect
beforeEach(() => {
vi.spyOn(React, 'useState').mockImplementation(useStateMock)
mockUseEffect = vi.spyOn(React, 'useEffect')
lastTime = new Date(new Date().valueOf() + 1000)
onRefresh.mockClear()
setState.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('stores last time checked, to avoid redundant runs', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { library: ['lib-1'] }, // Need some resources to trigger the update
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
// Mock useEffect to immediately call the effect callback
mockUseEffect.mockImplementation((callback) => callback())
useRefreshOnEvents({
events: ['library'],
onRefresh,
})
expect(setState).toHaveBeenCalledWith(lastTime)
})
it("does not run again if lastTime didn't change", () => {
vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
const useSelectorMock = () => ({ lastReceived: lastTime })
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
// Mock useEffect to immediately call the effect callback
mockUseEffect.mockImplementation((callback) => callback())
useRefreshOnEvents({
events: ['library'],
onRefresh,
})
expect(setState).not.toHaveBeenCalled()
expect(onRefresh).not.toHaveBeenCalled()
})
describe('Event listening and refresh triggering', () => {
beforeEach(() => {
// Mock useEffect to immediately call the effect callback
mockUseEffect.mockImplementation((callback) => callback())
})
it('triggers refresh when a watched event occurs', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { library: ['lib-1', 'lib-2'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['library'],
onRefresh,
})
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(setState).toHaveBeenCalledWith(lastTime)
})
it('triggers refresh when multiple watched events occur', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: {
library: ['lib-1'],
user: ['user-1'],
album: ['album-1'], // This shouldn't trigger since it's not watched
},
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['library', 'user'],
onRefresh,
})
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(setState).toHaveBeenCalledWith(lastTime)
})
it('does not trigger refresh when unwatched events occur', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { album: ['album-1'], song: ['song-1'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['library', 'user'],
onRefresh,
})
expect(onRefresh).not.toHaveBeenCalled()
expect(setState).not.toHaveBeenCalled()
})
it('triggers refresh on global refresh event', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { '*': '*' },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['library'],
onRefresh,
})
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(setState).toHaveBeenCalledWith(lastTime)
})
it('triggers refresh when listening to all events with "*"', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { song: ['song-1'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['*'],
onRefresh,
})
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(setState).toHaveBeenCalledWith(lastTime)
})
it('handles empty events array gracefully', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { library: ['lib-1'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: [],
onRefresh,
})
expect(onRefresh).not.toHaveBeenCalled()
expect(setState).not.toHaveBeenCalled()
})
it('handles missing onRefresh function gracefully', () => {
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { library: ['lib-1'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
expect(() => {
useRefreshOnEvents({
events: ['library'],
// onRefresh is undefined
})
}).not.toThrow()
expect(setState).toHaveBeenCalledWith(lastTime)
})
it('handles onRefresh errors gracefully', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const failingRefresh = vi
.fn()
.mockRejectedValue(new Error('Refresh failed'))
const useSelectorMock = () => ({
lastReceived: lastTime,
resources: { library: ['lib-1'] },
})
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useRefreshOnEvents({
events: ['library'],
onRefresh: failingRefresh,
})
expect(failingRefresh).toHaveBeenCalledTimes(1)
expect(setState).toHaveBeenCalledWith(lastTime)
// Wait for the promise to be rejected and handled
await new Promise((resolve) => setTimeout(resolve, 10))
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Error in useRefreshOnEvents onRefresh callback:',
expect.any(Error),
)
consoleWarnSpy.mockRestore()
})
})
})

View file

@ -2,6 +2,67 @@ import { useSelector } from 'react-redux'
import { useState } from 'react'
import { useRefresh, useDataProvider } from 'react-admin'
/**
* A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE.
*
* This hook is designed for components that display react-admin managed resources (like lists, shows, edits)
* and need to stay in sync when those resources are modified elsewhere in the application.
*
* **When to use this hook:**
* - Your component displays react-admin resources (albums, songs, artists, playlists, etc.)
* - You want automatic refresh when those resources are created/updated/deleted
* - Your data comes from standard dataProvider.getMany() calls
* - You're using react-admin's data management (queries, mutations, caching)
*
* **When NOT to use this hook:**
* - Your component displays derived/custom data not directly managed by react-admin
* - You need custom reload logic beyond dataProvider.getMany()
* - Your data comes from non-standard endpoints
* - Use `useRefreshOnEvents` instead for these scenarios
*
* @param {...string} visibleResources - Resource names to watch for changes.
* If no resources specified, watches all resources.
* If '*' is included in resources, triggers full page refresh.
*
* @example
* // Example 1: Album list - refresh when albums change
* const AlbumList = () => {
* useResourceRefresh('album')
* return <List resource="album">...</List>
* }
*
* @example
* // Example 2: Album show page - refresh when album or its songs change
* const AlbumShow = () => {
* useResourceRefresh('album', 'song')
* return <Show resource="album">...</Show>
* }
*
* @example
* // Example 3: Dashboard - refresh when any resource changes
* const Dashboard = () => {
* useResourceRefresh() // No parameters = watch all resources
* return <div>...</div>
* }
*
* @example
* // Example 4: Library management page - watch library resources
* const LibraryList = () => {
* useResourceRefresh('library')
* return <List resource="library">...</List>
* }
*
* **How it works:**
* - Listens to refresh events from the SSE connection
* - When events arrive, checks if they match the specified visible resources
* - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]})
* - For global refreshes: calls refresh() to reload the entire page
* - Uses react-admin's built-in data management and caching
*
* **Event format expected:**
* - Global refresh: { '*': '*' } or { someResource: ['*'] }
* - Specific resources: { album: ['id1', 'id2'], song: ['id3'] }
*/
export const useResourceRefresh = (...visibleResources) => {
const [lastTime, setLastTime] = useState(Date.now())
const refresh = useRefresh()

View file

@ -18,7 +18,7 @@ const LibraryFilter = (props) => (
const LibraryList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh()
useResourceRefresh('library')
return (
<List