mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-14 23:44:01 +02:00
refactor list internal state to target rerenders on change
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { itemListSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
@@ -84,6 +84,7 @@ export interface ItemListStateActions {
|
||||
getDraggingIds: () => string[];
|
||||
getExpanded: () => unknown[];
|
||||
getExpandedIds: () => string[];
|
||||
getExpandedItemsCached: () => unknown[];
|
||||
getSelected: () => unknown[];
|
||||
getSelectedIds: () => string[];
|
||||
getVersion: () => number;
|
||||
@@ -281,11 +282,122 @@ export const initialItemListState: ItemListState = {
|
||||
version: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* External store for item list state that doesn't cause React rerenders
|
||||
* Components can subscribe to specific state slices using useSyncExternalStore
|
||||
*/
|
||||
class ItemListStateStore {
|
||||
// Cache for derived values to prevent unnecessary rerenders
|
||||
private expandedItemsCache: null | unknown[] = null;
|
||||
private expandedItemsCacheVersion: number = -1;
|
||||
private listeners = new Set<() => void>();
|
||||
private state: ItemListState = { ...initialItemListState };
|
||||
|
||||
dispatch(action: ItemListAction): void {
|
||||
this.state = itemListReducer(this.state, action);
|
||||
// Invalidate caches when state changes
|
||||
this.expandedItemsCache = null;
|
||||
// Notify all subscribers
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
getExpandedItems(): unknown[] {
|
||||
// Return cached array if state version hasn't changed
|
||||
if (
|
||||
this.expandedItemsCache !== null &&
|
||||
this.expandedItemsCacheVersion === this.state.version
|
||||
) {
|
||||
return this.expandedItemsCache;
|
||||
}
|
||||
// Create new array and cache it
|
||||
this.expandedItemsCache = Array.from(this.state.expandedItems.values());
|
||||
this.expandedItemsCacheVersion = this.state.version;
|
||||
return this.expandedItemsCache;
|
||||
}
|
||||
|
||||
getState(): ItemListState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to specific state changes in the item list state
|
||||
* Use this in components that need to rerender when state changes
|
||||
*/
|
||||
export const useItemListStateSubscription = <T>(
|
||||
internalState: ItemListStateActions | undefined,
|
||||
selector: (state: ItemListState | null) => T,
|
||||
): T => {
|
||||
const store = internalState ? ((internalState as any).__store as ItemListStateStore) : null;
|
||||
|
||||
return useSyncExternalStore(
|
||||
store?.subscribe.bind(store) || (() => () => {}), // Return no-op unsubscribe if no store
|
||||
() => selector(store?.getState() || null),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to subscribe to selection state for a specific item
|
||||
* Use this in components that need to rerender when a specific item's selection changes
|
||||
*/
|
||||
export const useItemSelectionState = (
|
||||
internalState: ItemListStateActions | undefined,
|
||||
rowId: string | undefined,
|
||||
): boolean => {
|
||||
return useItemListStateSubscription(internalState, (state) =>
|
||||
state && rowId ? state.selected.has(rowId) : false,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to subscribe to expansion state for a specific item
|
||||
* Use this in components that need to rerender when a specific item's expansion changes
|
||||
*/
|
||||
export const useItemExpansionState = (
|
||||
internalState: ItemListStateActions | undefined,
|
||||
rowId: string | undefined,
|
||||
): boolean => {
|
||||
return useItemListStateSubscription(internalState, (state) =>
|
||||
state && rowId ? state.expanded.has(rowId) : false,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to subscribe to dragging state for a specific item
|
||||
* Use this in components that need to rerender when a specific item's dragging state changes
|
||||
*/
|
||||
export const useItemDraggingState = (
|
||||
internalState: ItemListStateActions | undefined,
|
||||
rowId: string | undefined,
|
||||
): boolean => {
|
||||
return useItemListStateSubscription(internalState, (state) =>
|
||||
state && rowId ? state.dragging.has(rowId) : false,
|
||||
);
|
||||
};
|
||||
|
||||
export const useItemListState = (
|
||||
getDataFn?: () => unknown[],
|
||||
extractRowId?: (item: unknown) => string | undefined,
|
||||
): ItemListStateActions => {
|
||||
const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
|
||||
// Create store instance (stable across rerenders)
|
||||
const storeRef = useRef<ItemListStateStore | null>(null);
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = new ItemListStateStore();
|
||||
}
|
||||
const store = storeRef.current;
|
||||
|
||||
// DON'T subscribe here - this prevents rerenders when state changes
|
||||
// Components that need to react should use useItemListStateSubscription
|
||||
|
||||
// Get current state (this doesn't cause rerenders, it's just reading from the store)
|
||||
const getCurrentState = useCallback(() => store.getState(), [store]);
|
||||
|
||||
const extractRowIdFn = useCallback(
|
||||
(item: unknown) => {
|
||||
@@ -303,138 +415,156 @@ export const useItemListState = (
|
||||
|
||||
const setExpanded = useCallback(
|
||||
(items: ItemListStateItemWithRequiredProperties[]) => {
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
extractRowId: extractRowIdFn,
|
||||
payload: items,
|
||||
type: 'SET_EXPANDED',
|
||||
});
|
||||
},
|
||||
[extractRowIdFn],
|
||||
[store, extractRowIdFn],
|
||||
);
|
||||
|
||||
const setDragging = useCallback(
|
||||
(items: ItemListStateItemWithRequiredProperties[]) => {
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
extractRowId: extractRowIdFn,
|
||||
payload: items,
|
||||
type: 'SET_DRAGGING',
|
||||
});
|
||||
},
|
||||
[extractRowIdFn],
|
||||
[store, extractRowIdFn],
|
||||
);
|
||||
|
||||
const setSelected = useCallback(
|
||||
(items: ItemListStateItemWithRequiredProperties[]) => {
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
extractRowId: extractRowIdFn,
|
||||
payload: items,
|
||||
type: 'SET_SELECTED',
|
||||
});
|
||||
},
|
||||
[extractRowIdFn],
|
||||
[store, extractRowIdFn],
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback(
|
||||
(item: ItemListStateItemWithRequiredProperties) => {
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
extractRowId: extractRowIdFn,
|
||||
payload: item,
|
||||
type: 'TOGGLE_EXPANDED',
|
||||
});
|
||||
},
|
||||
[extractRowIdFn],
|
||||
[store, extractRowIdFn],
|
||||
);
|
||||
|
||||
const toggleSelected = useCallback(
|
||||
(item: ItemListStateItemWithRequiredProperties) => {
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
extractRowId: extractRowIdFn,
|
||||
payload: item,
|
||||
type: 'TOGGLE_SELECTED',
|
||||
});
|
||||
},
|
||||
[extractRowIdFn],
|
||||
[store, extractRowIdFn],
|
||||
);
|
||||
|
||||
// These methods read from the store without subscribing, so they don't cause rerenders
|
||||
const isExpanded = useCallback(
|
||||
(rowId: string) => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.isExpanded(state, rowId);
|
||||
},
|
||||
[state],
|
||||
[getCurrentState],
|
||||
);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(rowId: string) => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.isSelected(state, rowId);
|
||||
},
|
||||
[state],
|
||||
[getCurrentState],
|
||||
);
|
||||
|
||||
const getExpanded = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.getExpanded(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const getExpandedItemsCached = useCallback(() => {
|
||||
return store.getExpandedItems();
|
||||
}, [store]);
|
||||
|
||||
const getDragging = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.getDragging(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const getSelected = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
const selectedItems = itemListSelectors.getSelected(state);
|
||||
const data = getDataFn ? getDataFn() : [];
|
||||
return sortByDataOrder(selectedItems, data, extractRowIdFn, false);
|
||||
}, [state, getDataFn, extractRowIdFn]);
|
||||
}, [getCurrentState, getDataFn, extractRowIdFn]);
|
||||
|
||||
const getDraggingIds = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return Array.from(state.dragging);
|
||||
}, [state.dragging]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const getExpandedIds = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return Array.from(state.expanded);
|
||||
}, [state.expanded]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const getSelectedIds = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
const selectedIds = Array.from(state.selected);
|
||||
const data = getDataFn ? getDataFn() : [];
|
||||
return sortByDataOrder(selectedIds, data, extractRowIdFn, true);
|
||||
}, [state.selected, getDataFn, extractRowIdFn]);
|
||||
}, [getCurrentState, getDataFn, extractRowIdFn]);
|
||||
|
||||
const clearExpanded = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_EXPANDED' });
|
||||
}, []);
|
||||
store.dispatch({ type: 'CLEAR_EXPANDED' });
|
||||
}, [store]);
|
||||
|
||||
const clearDragging = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_DRAGGING' });
|
||||
}, []);
|
||||
store.dispatch({ type: 'CLEAR_DRAGGING' });
|
||||
}, [store]);
|
||||
|
||||
const clearSelected = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_SELECTED' });
|
||||
}, []);
|
||||
store.dispatch({ type: 'CLEAR_SELECTED' });
|
||||
}, [store]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_ALL' });
|
||||
}, []);
|
||||
store.dispatch({ type: 'CLEAR_ALL' });
|
||||
}, [store]);
|
||||
|
||||
const getVersion = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.getVersion(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const hasExpanded = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.hasAnyExpanded(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const hasDragging = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.hasAnyDragging(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const hasSelected = useCallback(() => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.hasAnySelected(state);
|
||||
}, [state]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const isDragging = useCallback(
|
||||
(rowId: string) => {
|
||||
const state = getCurrentState();
|
||||
return itemListSelectors.isDragging(state, rowId);
|
||||
},
|
||||
[state],
|
||||
[getCurrentState],
|
||||
);
|
||||
|
||||
const getData = useCallback(() => {
|
||||
@@ -457,8 +587,12 @@ export const useItemListState = (
|
||||
[getDataFn, extractRowId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
// Expose the store so components can subscribe if needed
|
||||
// Store it in the actions object for access
|
||||
const actions = useMemo(() => {
|
||||
const actionsObj = {
|
||||
__getState: getCurrentState,
|
||||
__store: store,
|
||||
clearAll,
|
||||
clearDragging,
|
||||
clearExpanded,
|
||||
@@ -470,6 +604,7 @@ export const useItemListState = (
|
||||
getDraggingIds,
|
||||
getExpanded,
|
||||
getExpandedIds,
|
||||
getExpandedItemsCached,
|
||||
getSelected,
|
||||
getSelectedIds,
|
||||
getVersion,
|
||||
@@ -484,33 +619,41 @@ export const useItemListState = (
|
||||
setSelected,
|
||||
toggleExpanded,
|
||||
toggleSelected,
|
||||
}),
|
||||
[
|
||||
clearAll,
|
||||
clearDragging,
|
||||
clearExpanded,
|
||||
clearSelected,
|
||||
extractRowIdFn,
|
||||
findItemIndex,
|
||||
getData,
|
||||
getDragging,
|
||||
getDraggingIds,
|
||||
getExpanded,
|
||||
getExpandedIds,
|
||||
getSelected,
|
||||
getSelectedIds,
|
||||
getVersion,
|
||||
hasDragging,
|
||||
hasExpanded,
|
||||
hasSelected,
|
||||
isDragging,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
setDragging,
|
||||
setExpanded,
|
||||
setSelected,
|
||||
toggleExpanded,
|
||||
toggleSelected,
|
||||
],
|
||||
);
|
||||
} as ItemListStateActions & {
|
||||
__getState: () => ItemListState;
|
||||
__store: ItemListStateStore;
|
||||
};
|
||||
return actionsObj;
|
||||
}, [
|
||||
clearAll,
|
||||
clearDragging,
|
||||
clearExpanded,
|
||||
clearSelected,
|
||||
extractRowIdFn,
|
||||
findItemIndex,
|
||||
getData,
|
||||
getDragging,
|
||||
getDraggingIds,
|
||||
getExpanded,
|
||||
getExpandedIds,
|
||||
getExpandedItemsCached,
|
||||
getSelected,
|
||||
getSelectedIds,
|
||||
getVersion,
|
||||
hasDragging,
|
||||
hasExpanded,
|
||||
hasSelected,
|
||||
isDragging,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
setDragging,
|
||||
setExpanded,
|
||||
setSelected,
|
||||
toggleExpanded,
|
||||
toggleSelected,
|
||||
store,
|
||||
getCurrentState,
|
||||
]);
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user