diff --git a/src/app/store/__tests__/selectorHooks.test.tsx b/src/app/store/__tests__/selectorHooks.test.tsx new file mode 100644 index 00000000..89e94564 --- /dev/null +++ b/src/app/store/__tests__/selectorHooks.test.tsx @@ -0,0 +1,55 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + useMessagingStore, + useMessagingStoreSelector, +} from '../messagingStore'; +import { + useNotificationStore, + useNotificationStoreSelector, +} from '../notificationStore'; +import { + useVolunteerStore, + useVolunteerStoreSelector, +} from '../volunteerStore'; + +describe('store selector hooks', () => { + it('does not re-render a messaging selector for unrelated state changes', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useMessagingStoreSelector((state) => state.messages); + }); + + act(() => useMessagingStore.getState().setTyping(!useMessagingStore.getState().isTyping)); + + expect(result.current).toEqual([]); + expect(renders).toBe(1); + }); + + it('does not re-render a notification selector for unrelated state changes', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useNotificationStoreSelector((state) => state.notifications); + }); + + act(() => useNotificationStore.setState({ clearRead: () => undefined })); + + expect(result.current).toEqual([]); + expect(renders).toBe(1); + }); + + it('does not re-render a volunteer selector for unrelated state changes', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useVolunteerStoreSelector((state) => state.volunteers); + }); + + act(() => useVolunteerStore.setState({ removeVolunteer: () => undefined })); + + expect(result.current).toEqual([]); + expect(renders).toBe(1); + }); +}); diff --git a/src/app/store/messagingStore.ts b/src/app/store/messagingStore.ts index 00fed7dd..d43fbf0e 100644 --- a/src/app/store/messagingStore.ts +++ b/src/app/store/messagingStore.ts @@ -79,6 +79,12 @@ interface MessagingState { getTotalUnreadCount: () => number; } +/** + * Subscribe to a focused slice of messaging state instead of the entire store. + */ +export const useMessagingStoreSelector = (selector: (state: MessagingState) => T): T => + useMessagingStore(selector); + export const useMessagingStore = create((set, get) => ({ conversations: [], currentConversation: null, diff --git a/src/app/store/notificationStore.ts b/src/app/store/notificationStore.ts index 76a7a2b8..73014de8 100644 --- a/src/app/store/notificationStore.ts +++ b/src/app/store/notificationStore.ts @@ -42,6 +42,12 @@ interface NotificationState { clearRead: () => void; } +/** + * Subscribe to a focused slice of notification state instead of the entire store. + */ +export const useNotificationStoreSelector = (selector: (state: NotificationState) => T): T => + useNotificationStore(selector); + export const useNotificationStore = create((set, get) => ({ notifications: load(STORAGE_KEY, [], dateReviver), addNotification: (n) => { diff --git a/src/app/store/volunteerStore.ts b/src/app/store/volunteerStore.ts index c050fe4d..27af3524 100644 --- a/src/app/store/volunteerStore.ts +++ b/src/app/store/volunteerStore.ts @@ -28,6 +28,12 @@ interface VolunteerState { updateSMSPreferences: (id: string, sms: Partial) => void; } +/** + * Subscribe to a focused slice of volunteer state instead of the entire store. + */ +export const useVolunteerStoreSelector = (selector: (state: VolunteerState) => T): T => + useVolunteerStore(selector); + export const useVolunteerStore = create((set, get) => ({ volunteers: load(STORAGE_KEY, []),