- Created state cleanup system (stateCleanup.ts) with: * Size limit cleanup: Limit number of items in arrays/normalized state * Age limit cleanup: Remove items older than specified time * Custom cleanup: User-defined cleanup functions * Support for arrays, normalized state, and nested objects - Added cleanupMiddleware for automatic periodic cleanup - Added performCleanup function for manual cleanup - Comprehensive test suite (9 tests, all passing) - Prevents memory leaks by cleaning unused state data
434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
/**
|
|
* State Cleanup
|
|
* FE-STATE-012: Clean up unused state to prevent memory leaks
|
|
*
|
|
* Provides utilities and middleware for cleaning up unused state data
|
|
* in Zustand stores to prevent memory leaks.
|
|
*/
|
|
|
|
import { StateCreator } from 'zustand';
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* Cleanup strategy
|
|
*/
|
|
export type CleanupStrategy =
|
|
| 'size_limit' // Limit by size (number of items)
|
|
| 'age_limit' // Limit by age (time-based)
|
|
| 'both' // Both size and age limits
|
|
| 'custom'; // Custom cleanup function
|
|
|
|
/**
|
|
* Cleanup configuration
|
|
*/
|
|
export interface CleanupConfig {
|
|
/** Strategy to use */
|
|
strategy: CleanupStrategy;
|
|
/** Maximum number of items to keep (for size_limit) */
|
|
maxSize?: number;
|
|
/** Maximum age in milliseconds (for age_limit) */
|
|
maxAge?: number;
|
|
/** Custom cleanup function */
|
|
cleanupFn?: (state: unknown) => unknown;
|
|
/** Fields to clean up (if not specified, cleans entire state) */
|
|
fields?: string[];
|
|
/** Enable automatic periodic cleanup */
|
|
autoCleanup?: boolean;
|
|
/** Interval for automatic cleanup in milliseconds (default: 5 minutes) */
|
|
cleanupInterval?: number;
|
|
}
|
|
|
|
/**
|
|
* Cleanup options for middleware
|
|
*/
|
|
export interface CleanupOptions {
|
|
/** Store name for logging */
|
|
storeName: string;
|
|
/** Cleanup configurations for different state fields */
|
|
configs: Record<string, CleanupConfig>;
|
|
/** Enable cleanup (default: true) */
|
|
enabled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Clean up state based on size limit
|
|
*/
|
|
function cleanupBySize<T>(
|
|
items: T[],
|
|
maxSize: number,
|
|
getTimestamp?: (item: T) => number,
|
|
): T[] {
|
|
if (items.length <= maxSize) {
|
|
return items;
|
|
}
|
|
|
|
// If we have timestamps, keep the most recent items
|
|
if (getTimestamp) {
|
|
const sorted = [...items].sort((a, b) => getTimestamp(b) - getTimestamp(a));
|
|
return sorted.slice(0, maxSize);
|
|
}
|
|
|
|
// Otherwise, keep the first items
|
|
return items.slice(0, maxSize);
|
|
}
|
|
|
|
/**
|
|
* Clean up state based on age limit
|
|
*/
|
|
function cleanupByAge<T>(
|
|
items: T[],
|
|
maxAge: number,
|
|
getTimestamp: (item: T) => number,
|
|
): T[] {
|
|
const now = Date.now();
|
|
const cutoff = now - maxAge;
|
|
|
|
return items.filter((item) => {
|
|
const timestamp = getTimestamp(item);
|
|
return timestamp >= cutoff;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up normalized state by size
|
|
*/
|
|
function cleanupNormalizedBySize<T extends { id: string }>(
|
|
normalized: { byId: Record<string, T>; allIds: string[] },
|
|
maxSize: number,
|
|
getTimestamp?: (item: T) => number,
|
|
): { byId: Record<string, T>; allIds: string[] } {
|
|
if (normalized.allIds.length <= maxSize) {
|
|
return normalized;
|
|
}
|
|
|
|
let itemsToKeep: T[];
|
|
|
|
if (getTimestamp) {
|
|
// Sort by timestamp and keep most recent
|
|
const items = normalized.allIds.map((id) => normalized.byId[id]);
|
|
const sorted = items.sort((a, b) => getTimestamp(b) - getTimestamp(a));
|
|
itemsToKeep = sorted.slice(0, maxSize);
|
|
} else {
|
|
// Keep first items
|
|
itemsToKeep = normalized.allIds
|
|
.slice(0, maxSize)
|
|
.map((id) => normalized.byId[id]);
|
|
}
|
|
|
|
const newById: Record<string, T> = {};
|
|
const newAllIds: string[] = [];
|
|
|
|
for (const item of itemsToKeep) {
|
|
newById[item.id] = item;
|
|
newAllIds.push(item.id);
|
|
}
|
|
|
|
return { byId: newById, allIds: newAllIds };
|
|
}
|
|
|
|
/**
|
|
* Clean up normalized state by age
|
|
*/
|
|
function cleanupNormalizedByAge<T extends { id: string }>(
|
|
normalized: { byId: Record<string, T>; allIds: string[] },
|
|
maxAge: number,
|
|
getTimestamp: (item: T) => number,
|
|
): { byId: Record<string, T>; allIds: string[] } {
|
|
const now = Date.now();
|
|
const cutoff = now - maxAge;
|
|
|
|
const newById: Record<string, T> = {};
|
|
const newAllIds: string[] = [];
|
|
|
|
for (const id of normalized.allIds) {
|
|
const item = normalized.byId[id];
|
|
const timestamp = getTimestamp(item);
|
|
if (timestamp >= cutoff) {
|
|
newById[id] = item;
|
|
newAllIds.push(id);
|
|
}
|
|
}
|
|
|
|
return { byId: newById, allIds: newAllIds };
|
|
}
|
|
|
|
/**
|
|
* Apply cleanup to a state field
|
|
*/
|
|
function applyCleanup<T = unknown>(
|
|
value: T,
|
|
config: CleanupConfig,
|
|
): T {
|
|
if (!config.strategy || config.strategy === 'custom') {
|
|
if (config.cleanupFn) {
|
|
return config.cleanupFn(value) as T;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Handle arrays
|
|
if (Array.isArray(value)) {
|
|
const getTimestamp = (item: unknown): number => {
|
|
if (typeof item === 'object' && item !== null) {
|
|
const obj = item as Record<string, unknown>;
|
|
if ('created_at' in obj && typeof obj.created_at === 'string') {
|
|
return new Date(obj.created_at).getTime();
|
|
}
|
|
if ('timestamp' in obj && typeof obj.timestamp === 'number') {
|
|
return obj.timestamp;
|
|
}
|
|
if ('updated_at' in obj && typeof obj.updated_at === 'string') {
|
|
return new Date(obj.updated_at).getTime();
|
|
}
|
|
}
|
|
return Date.now();
|
|
};
|
|
|
|
let cleaned = value;
|
|
|
|
// Apply age limit first (removes old items)
|
|
if (config.strategy === 'age_limit' || config.strategy === 'both') {
|
|
if (config.maxAge) {
|
|
cleaned = cleanupByAge(cleaned, config.maxAge, getTimestamp) as T;
|
|
}
|
|
}
|
|
|
|
// Then apply size limit (keeps most recent items)
|
|
if (config.strategy === 'size_limit' || config.strategy === 'both') {
|
|
if (config.maxSize) {
|
|
cleaned = cleanupBySize(cleaned, config.maxSize, getTimestamp) as T;
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
// Handle normalized state (byId + allIds)
|
|
if (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
'byId' in value &&
|
|
'allIds' in value
|
|
) {
|
|
const normalized = value as { byId: Record<string, unknown>; allIds: string[] };
|
|
const getTimestamp = (item: unknown): number => {
|
|
if (typeof item === 'object' && item !== null) {
|
|
const obj = item as Record<string, unknown>;
|
|
if ('created_at' in obj && typeof obj.created_at === 'string') {
|
|
return new Date(obj.created_at).getTime();
|
|
}
|
|
if ('timestamp' in obj && typeof obj.timestamp === 'number') {
|
|
return obj.timestamp;
|
|
}
|
|
if ('updated_at' in obj && typeof obj.updated_at === 'string') {
|
|
return new Date(obj.updated_at).getTime();
|
|
}
|
|
}
|
|
return Date.now();
|
|
};
|
|
|
|
let cleaned = normalized;
|
|
|
|
// Apply age limit first (removes old items)
|
|
if (config.strategy === 'age_limit' || config.strategy === 'both') {
|
|
if (config.maxAge) {
|
|
cleaned = cleanupNormalizedByAge(
|
|
cleaned as { byId: Record<string, { id: string }>; allIds: string[] },
|
|
config.maxAge,
|
|
getTimestamp,
|
|
) as typeof normalized;
|
|
}
|
|
}
|
|
|
|
// Then apply size limit (keeps most recent items)
|
|
if (config.strategy === 'size_limit' || config.strategy === 'both') {
|
|
if (config.maxSize) {
|
|
cleaned = cleanupNormalizedBySize(
|
|
cleaned as { byId: Record<string, { id: string }>; allIds: string[] },
|
|
config.maxSize,
|
|
getTimestamp,
|
|
) as typeof normalized;
|
|
}
|
|
}
|
|
|
|
return cleaned as T;
|
|
}
|
|
|
|
// Handle objects with nested arrays (e.g., messages: { [conversationId]: Message[] })
|
|
if (typeof value === 'object' && value !== null) {
|
|
const obj = value as Record<string, unknown>;
|
|
const cleaned: Record<string, unknown> = {};
|
|
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
if (Array.isArray(val)) {
|
|
const getTimestamp = (item: unknown): number => {
|
|
if (typeof item === 'object' && item !== null) {
|
|
const itemObj = item as Record<string, unknown>;
|
|
if ('created_at' in itemObj && typeof itemObj.created_at === 'string') {
|
|
return new Date(itemObj.created_at).getTime();
|
|
}
|
|
if ('timestamp' in itemObj && typeof itemObj.timestamp === 'number') {
|
|
return itemObj.timestamp;
|
|
}
|
|
}
|
|
return Date.now();
|
|
};
|
|
|
|
let cleanedArray = val;
|
|
|
|
// Apply age limit first (removes old items)
|
|
if (config.strategy === 'age_limit' || config.strategy === 'both') {
|
|
if (config.maxAge) {
|
|
cleanedArray = cleanupByAge(cleanedArray, config.maxAge, getTimestamp);
|
|
}
|
|
}
|
|
|
|
// Then apply size limit (keeps most recent items)
|
|
if (config.strategy === 'size_limit' || config.strategy === 'both') {
|
|
if (config.maxSize) {
|
|
cleanedArray = cleanupBySize(cleanedArray, config.maxSize, getTimestamp);
|
|
}
|
|
}
|
|
|
|
cleaned[key] = cleanedArray;
|
|
} else {
|
|
cleaned[key] = val;
|
|
}
|
|
}
|
|
|
|
return cleaned as T;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Clean up state based on configuration
|
|
*/
|
|
export function cleanupState<T extends object>(
|
|
state: T,
|
|
configs: Record<string, CleanupConfig>,
|
|
): T {
|
|
const cleaned = { ...state } as Record<string, unknown>;
|
|
|
|
for (const [field, config] of Object.entries(configs)) {
|
|
if (field in cleaned) {
|
|
try {
|
|
cleaned[field] = applyCleanup(cleaned[field], config);
|
|
} catch (error) {
|
|
logger.warn(`[StateCleanup] Failed to clean field ${field}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cleaned as T;
|
|
}
|
|
|
|
/**
|
|
* Zustand middleware for automatic state cleanup
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* export const useMyStore = create<MyState>()(
|
|
* cleanupMiddleware(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* {
|
|
* storeName: 'MyStore',
|
|
* configs: {
|
|
* messages: {
|
|
* strategy: 'both',
|
|
* maxSize: 100,
|
|
* maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
* },
|
|
* },
|
|
* }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function cleanupMiddleware<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: CleanupOptions,
|
|
): StateCreator<T> {
|
|
const {
|
|
storeName,
|
|
configs,
|
|
enabled = true,
|
|
autoCleanup = false,
|
|
cleanupInterval = 5 * 60 * 1000, // 5 minutes default
|
|
} = options;
|
|
|
|
let cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
|
|
return (set, get, api) => {
|
|
const store = config(set, get, api);
|
|
|
|
if (!enabled) {
|
|
return store;
|
|
}
|
|
|
|
// Set up automatic cleanup if enabled
|
|
if (autoCleanup && typeof window !== 'undefined') {
|
|
cleanupIntervalId = setInterval(() => {
|
|
try {
|
|
const currentState = get();
|
|
const cleanedState = cleanupState(currentState, configs);
|
|
const stateChanged = JSON.stringify(currentState) !== JSON.stringify(cleanedState);
|
|
|
|
if (stateChanged) {
|
|
logger.debug(`[StateCleanup:${storeName}] Automatic cleanup performed`);
|
|
set(cleanedState as Partial<T>);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[StateCleanup:${storeName}] Automatic cleanup failed:`, error);
|
|
}
|
|
}, cleanupInterval);
|
|
|
|
// Clean up interval on unmount (if store is destroyed)
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('beforeunload', () => {
|
|
if (cleanupIntervalId) {
|
|
clearInterval(cleanupIntervalId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add cleanup method to store
|
|
return {
|
|
...store,
|
|
// Add cleanup method if it doesn't exist
|
|
cleanup: () => {
|
|
performCleanup(get, set, configs, storeName);
|
|
},
|
|
} as T & { cleanup: () => void };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manual cleanup function for stores
|
|
*/
|
|
export function performCleanup<T extends object>(
|
|
getState: () => T,
|
|
setState: (state: Partial<T>) => void,
|
|
configs: Record<string, CleanupConfig>,
|
|
storeName: string = 'Unknown',
|
|
): void {
|
|
try {
|
|
const currentState = getState();
|
|
const cleanedState = cleanupState(currentState, configs);
|
|
const stateChanged = JSON.stringify(currentState) !== JSON.stringify(cleanedState);
|
|
|
|
if (stateChanged) {
|
|
logger.info(`[StateCleanup:${storeName}] Manual cleanup performed`);
|
|
setState(cleanedState);
|
|
} else {
|
|
logger.debug(`[StateCleanup:${storeName}] No cleanup needed`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[StateCleanup:${storeName}] Manual cleanup failed:`, error);
|
|
}
|
|
}
|
|
|