From 9492569a2c59d90117e7f4043ee439f0916be8dd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:19:15 +1000 Subject: [PATCH] wip --- invokeai/frontend/web/eslint.config.mjs | 2 + .../web/src/app/components/InvokeAIUI.tsx | 21 ++- .../store/enhancers/reduxRemember/driver.ts | 152 ++++++++++-------- invokeai/frontend/web/src/app/store/store.ts | 7 +- .../web/src/common/hooks/useClearStorage.ts | 5 +- invokeai/frontend/web/src/index.ts | 1 + invokeai/frontend/web/src/main.tsx | 20 ++- 7 files changed, 134 insertions(+), 74 deletions(-) diff --git a/invokeai/frontend/web/eslint.config.mjs b/invokeai/frontend/web/eslint.config.mjs index 6449cfb627..040fa3eb46 100644 --- a/invokeai/frontend/web/eslint.config.mjs +++ b/invokeai/frontend/web/eslint.config.mjs @@ -123,6 +123,8 @@ export default [ }, ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/consistent-type-imports': [ 'error', { diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index b365faf244..c471378b3a 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -5,6 +5,12 @@ import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; import { $didStudioInit } from 'app/hooks/useStudioInitAction'; import type { LoggingOverrides } from 'app/logging/logger'; import { $loggingOverrides, configureLogging } from 'app/logging/logger'; +import { + $resetClientState, + buildDriver, + buildResetClientState, + type StorageDriverApi, +} from 'app/store/enhancers/reduxRemember/driver'; import { $accountSettingsLink } from 'app/store/nanostores/accountSettingsLink'; import { $authToken } from 'app/store/nanostores/authToken'; import { $baseUrl } from 'app/store/nanostores/baseUrl'; @@ -70,6 +76,7 @@ interface Props extends PropsWithChildren { * If provided, overrides in-app navigation to the model manager */ onClickGoToModelManager?: () => void; + storageDriverApi?: StorageDriverApi; } const InvokeAIUI = ({ @@ -96,6 +103,7 @@ const InvokeAIUI = ({ loggingOverrides, onClickGoToModelManager, whatsNew, + storageDriverApi, }: Props) => { useLayoutEffect(() => { /* @@ -308,9 +316,18 @@ const InvokeAIUI = ({ }; }, [isDebugging]); + useEffect(() => { + $resetClientState.set(buildResetClientState(storageDriverApi)); + + return () => { + $resetClientState.set(() => {}); + }; + }, [storageDriverApi]); + const store = useMemo(() => { - return createStore(projectId); - }, [projectId]); + const driver = buildDriver(storageDriverApi); + return createStore(driver); + }, [storageDriverApi]); useEffect(() => { $store.set(store); diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts index ee9e29b41a..0035de0450 100644 --- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts @@ -3,10 +3,17 @@ import { StorageError } from 'app/store/enhancers/reduxRemember/errors'; import { $authToken } from 'app/store/nanostores/authToken'; import { $projectId } from 'app/store/nanostores/projectId'; import { $queueId } from 'app/store/nanostores/queueId'; +import { atom } from 'nanostores'; import type { Driver } from 'redux-remember'; import { getBaseUrl } from 'services/api'; import { buildAppInfoUrl } from 'services/api/endpoints/appInfo'; +export type StorageDriverApi = { + getItem: (key: string) => Promise; + setItem: (key: string, value: any) => Promise; + clear: () => Promise; +}; + const log = logger('system'); // Persistence happens per slice. To track when persistence is in progress, maintain a ref count, incrementing @@ -47,77 +54,94 @@ const getHeaders = (extra?: Record) => { return headers; }; -export const serverBackedDriver: Driver = { - getItem: async (key) => { - try { - const url = getUrl(key); - const headers = getHeaders(); - const res = await fetch(url, { headers, method: 'GET' }); - if (!res.ok) { - throw new Error(`Response status: ${res.status}`); +export const buildDriver = (api?: StorageDriverApi): Driver => { + return { + getItem: async (key) => { + try { + if (api) { + log.trace(`Using provided API to get item for key "${key}"`); + return await api.getItem(key); + } + const url = getUrl(key); + const headers = getHeaders(); + const res = await fetch(url, { headers, method: 'GET' }); + if (!res.ok) { + throw new Error(`Response status: ${res.status}`); + } + const json = await res.json(); + return json; + } catch (originalError) { + throw new StorageError({ + key, + projectId: $projectId.get(), + originalError, + }); } - const json = await res.json(); - return json; - } catch (originalError) { - throw new StorageError({ - key, - projectId: $projectId.get(), - originalError, - }); - } - }, - setItem: async (key, value) => { - try { - persistRefCount++; - // Deep equality check to avoid noop persist network requests. - // - // `redux-remember` persists individual slices of state, so we can implicity denylist a slice by not giving it a - // persist config. - // - // However, we may need to avoid persisting individual _fields_ of a slice. `redux-remember` does not provide a - // way to do this directly. - // - // To accomplish this, we add a layer of logic on top of the `redux-remember`. In the state serializer function - // provided to `redux-remember`, we can omit certain fields from the state that we do not want to persist. See - // the implementation in `store.ts` for this logic. - // - // This logic is unknown to `redux-remember`. When an omitted field changes, it will still attempt to persist the - // whole slice, even if the final, _serialized_ slice value is unchanged. - // - // To avoid unnecessary network requests, we keep track of the last persisted state for each key. If the value to - // be persisted is the same as the last persisted value, we skip the network request. - if (lastPersistedState.get(key) === value) { - log.trace(`Skipping persist for key "${key}" as value is unchanged.`); + }, + setItem: async (key, value) => { + try { + persistRefCount++; + if (api) { + log.trace(`Using provided API to get item for key "${key}"`); + return await api.setItem(key, value); + } + // Deep equality check to avoid noop persist network requests. + // + // `redux-remember` persists individual slices of state, so we can implicity denylist a slice by not giving it a + // persist config. + // + // However, we may need to avoid persisting individual _fields_ of a slice. `redux-remember` does not provide a + // way to do this directly. + // + // To accomplish this, we add a layer of logic on top of the `redux-remember`. In the state serializer function + // provided to `redux-remember`, we can omit certain fields from the state that we do not want to persist. See + // the implementation in `store.ts` for this logic. + // + // This logic is unknown to `redux-remember`. When an omitted field changes, it will still attempt to persist the + // whole slice, even if the final, _serialized_ slice value is unchanged. + // + // To avoid unnecessary network requests, we keep track of the last persisted state for each key. If the value to + // be persisted is the same as the last persisted value, we skip the network request. + if (lastPersistedState.get(key) === value) { + log.trace(`Skipping persist for key "${key}" as value is unchanged.`); + return value; + } + const url = getUrl(key); + const headers = getHeaders({ 'content-type': 'application/json' }); + const res = await fetch(url, { headers, method: 'POST', body: JSON.stringify(value) }); + if (!res.ok) { + throw new Error(`Response status: ${res.status}`); + } + lastPersistedState.set(key, value); return value; + } catch (originalError) { + throw new StorageError({ + key, + value, + projectId: $projectId.get(), + originalError, + }); + } finally { + persistRefCount--; + if (persistRefCount < 0) { + log.warn('Persist ref count is negative, resetting to 0'); + persistRefCount = 0; + } } - const url = getUrl(key); - const headers = getHeaders({ 'content-type': 'application/json' }); - const res = await fetch(url, { headers, method: 'POST', body: JSON.stringify(value) }); - if (!res.ok) { - throw new Error(`Response status: ${res.status}`); - } - lastPersistedState.set(key, value); - return value; - } catch (originalError) { - throw new StorageError({ - key, - value, - projectId: $projectId.get(), - originalError, - }); - } finally { - persistRefCount--; - if (persistRefCount < 0) { - log.warn('Persist ref count is negative, resetting to 0'); - persistRefCount = 0; - } - } - }, + }, + }; }; -export const resetClientState = async () => { +export const $resetClientState = atom(() => {}); + +export const buildResetClientState = (api?: StorageDriverApi) => async () => { try { persistRefCount++; + if (api) { + log.trace('Using provided API to reset client state'); + await api.clear(); + return; + } const url = getUrl(); const headers = getHeaders(); const res = await fetch(url, { headers, method: 'DELETE' }); diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 1ef09c608a..dc69c58ed2 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -1,7 +1,6 @@ import type { ThunkDispatch, TypedStartListening, UnknownAction } from '@reduxjs/toolkit'; import { addListener, combineReducers, configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; -import { serverBackedDriver } from 'app/store/enhancers/reduxRemember/driver'; import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; @@ -41,7 +40,7 @@ import { systemSliceConfig } from 'features/system/store/systemSlice'; import { uiSliceConfig } from 'features/ui/store/uiSlice'; import { diff } from 'jsondiffpatch'; import dynamicMiddlewares from 'redux-dynamic-middlewares'; -import type { SerializeFunction, UnserializeFunction } from 'redux-remember'; +import type { Driver, SerializeFunction, UnserializeFunction } from 'redux-remember'; import { rememberEnhancer, rememberReducer } from 'redux-remember'; import undoable, { newHistory } from 'redux-undo'; import { serializeError } from 'serialize-error'; @@ -185,7 +184,7 @@ const PERSISTED_KEYS = Object.values(SLICE_CONFIGS) .filter((sliceConfig) => !!sliceConfig.persistConfig) .map((sliceConfig) => sliceConfig.slice.reducerPath); -export const createStore = (uniqueStoreKey?: string, persist = true) => +export const createStore = (driver: Driver, persist = true) => configureStore({ reducer: rememberedRootReducer, middleware: (getDefaultMiddleware) => @@ -204,7 +203,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => const enhancers = getDefaultEnhancers(); if (persist) { const res = enhancers.prepend( - rememberEnhancer(serverBackedDriver, PERSISTED_KEYS, { + rememberEnhancer(driver, PERSISTED_KEYS, { persistThrottle: 2000, serialize, unserialize, diff --git a/invokeai/frontend/web/src/common/hooks/useClearStorage.ts b/invokeai/frontend/web/src/common/hooks/useClearStorage.ts index 9e8931f2ad..cf02e8212e 100644 --- a/invokeai/frontend/web/src/common/hooks/useClearStorage.ts +++ b/invokeai/frontend/web/src/common/hooks/useClearStorage.ts @@ -1,10 +1,9 @@ -import { resetClientState } from 'app/store/enhancers/reduxRemember/driver'; +import { $resetClientState } from 'app/store/enhancers/reduxRemember/driver'; import { useCallback } from 'react'; export const useClearStorage = () => { const clearStorage = useCallback(() => { - // clearIdbKeyValStore(); - resetClientState(); + $resetClientState.get()(); localStorage.clear(); }, []); diff --git a/invokeai/frontend/web/src/index.ts b/invokeai/frontend/web/src/index.ts index fd6ab14f9b..c69ef7ea92 100644 --- a/invokeai/frontend/web/src/index.ts +++ b/invokeai/frontend/web/src/index.ts @@ -31,6 +31,7 @@ import { export { default as InvokeAIUI } from './app/components/InvokeAIUI'; export type { StudioInitAction } from './app/hooks/useStudioInitAction'; export type { LoggingOverrides } from './app/logging/logger'; +export type { StorageDriverApi } from './app/store/enhancers/reduxRemember/driver'; export type { PartialAppConfig } from './app/types/invokeai'; export { default as HotkeysModal } from './features/system/components/HotkeysModal/HotkeysModal'; export { default as InvokeAiLogoComponent } from './features/system/components/InvokeAILogoComponent'; diff --git a/invokeai/frontend/web/src/main.tsx b/invokeai/frontend/web/src/main.tsx index acf9491778..86cfdb6470 100644 --- a/invokeai/frontend/web/src/main.tsx +++ b/invokeai/frontend/web/src/main.tsx @@ -1,5 +1,23 @@ +import type { StorageDriverApi } from 'app/store/enhancers/reduxRemember/driver'; import ReactDOM from 'react-dom/client'; import InvokeAIUI from './app/components/InvokeAIUI'; -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); +let state: Record = {}; +const storageDriverApi: StorageDriverApi = { + getItem: (key: string) => { + return Promise.resolve(state[key]); + }, + setItem: (key: string, value: any) => { + state[key] = value; + return Promise.resolve(value); + }, + clear: () => { + state = {}; + return Promise.resolve(); + }, +}; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + +);