feat(navigator): reactive favourites via fine-grained store (P3)

Move favourite room ids out of the useBetween navigator store into a
dedicated Zustand store. useNavigatorFavourite(roomId) subscribes only
to s.ids.has(roomId) (a boolean), so a FavouriteChangedEvent for one
room no longer re-renders every favourite-aware view. apply() returns
the same state reference when membership is unchanged.
This commit is contained in:
simoleo89
2026-05-31 01:03:31 +02:00
parent 641593c3ef
commit e610cfeef4
9 changed files with 103 additions and 41 deletions
+1
View File
@@ -1,4 +1,5 @@
export { useNavigatorData } from './useNavigatorData';
export { useNavigatorFavourite } from './useNavigatorFavourite';
export { useNavigatorSearch } from './useNavigatorSearch';
export { useNavigatorUiState } from './useNavigatorUiState';
export { useNavigatorUiStore } from './navigatorUiStore';
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set<number>() });
describe('navigatorFavouritesStore', () =>
{
beforeEach(reset);
it('setAll replaces membership and coerces ids to numbers', () =>
{
useNavigatorFavouritesStore.getState().setAll([ '1', 2, '3' ] as any);
const { ids } = useNavigatorFavouritesStore.getState();
expect(ids.has(1)).toBe(true);
expect(ids.has(2)).toBe(true);
expect(ids.has(3)).toBe(true);
expect(ids.size).toBe(3);
});
it('apply(true) adds and apply(false) removes', () =>
{
const { apply } = useNavigatorFavouritesStore.getState();
apply(7, true);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(true);
apply(7, false);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(false);
});
it('apply returns the same state reference when nothing changes (no needless re-render)', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(5, true); // already present
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
useNavigatorFavouritesStore.getState().apply(99, false); // already absent
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
});
it('apply creates a new Set reference when membership actually changes', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(6, true);
expect(useNavigatorFavouritesStore.getState().ids).not.toBe(before);
});
});
@@ -0,0 +1,25 @@
import { createNitroStore } from '../../state/createNitroStore';
export type NavigatorFavouritesState = {
ids: Set<number>;
};
export type NavigatorFavouritesActions = {
setAll(roomIds: number[]): void;
apply(roomId: number, added: boolean): void;
};
export const useNavigatorFavouritesStore = createNitroStore<NavigatorFavouritesState & NavigatorFavouritesActions>()((set) => ({
ids: new Set<number>(),
setAll: (roomIds) => set({ ids: new Set(roomIds.map(Number)) }),
apply: (roomId, added) => set((s) =>
{
const id = Number(roomId);
if(added ? s.ids.has(id) : !s.ids.has(id)) return s;
const ids = new Set(s.ids);
if(added) ids.add(id);
else ids.delete(id);
return { ids };
})
}));
+2 -2
View File
@@ -4,13 +4,13 @@ import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorData = () =>
{
const {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
} = useBetween(useNavigatorStore);
return {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};
@@ -0,0 +1,12 @@
import { useCallback } from 'react';
import { ToggleFavoriteRoom } from '../../api';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
export const useNavigatorFavourite = (roomId: number) =>
{
const isFavourite = useNavigatorFavouritesStore((s) => s.ids.has(Number(roomId)));
const toggle = useCallback(() => ToggleFavoriteRoom(Number(roomId), isFavourite), [ roomId, isFavourite ]);
return { isFavourite, toggle };
};
@@ -8,7 +8,7 @@ describe('navigator filter shapes (smoke)', () =>
{
const { result } = renderHook(() => useNavigatorData());
expect(Object.keys(result.current).sort()).toEqual([
'categories', 'eventCategories', 'favouriteRoomIds',
'categories', 'eventCategories',
'navigatorData', 'navigatorSearches',
'topLevelContext', 'topLevelContexts'
].sort());
+4 -12
View File
@@ -18,13 +18,13 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData,
TryVisitRoom, VisitDesktop } from '../../api';
import { useMessageEvent, useNitroEvent } from '../events';
import { useNotification } from '../notification';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorStore = () =>
{
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
@@ -48,21 +48,13 @@ export const useNavigatorStore = () =>
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
{
const parser = event.getParser();
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
setFavouriteRoomIds(favoriteIds);
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
}, []));
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
{
const parser = event.getParser();
const roomId = Number(parser.flatId);
const added = !!parser.added;
setFavouriteRoomIds(prev =>
{
const ids = (prev || []).map((x: any) => Number(x));
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
return ids.filter(id => id !== roomId);
});
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
}, []));
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
@@ -280,7 +272,7 @@ export const useNavigatorStore = () =>
}, []));
return {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};