diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index fa1b43fee..d69b5b21b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,14 +1,14 @@ import 'core-js/actual'; import '@atlaskit/css-reset'; -import { RivetApp } from './components/RivetApp.js'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RivetAppLoader } from './components/RivetAppLoader'; const queryClient = new QueryClient(); function App() { return ( - + ); } diff --git a/packages/app/src/components/RivetApp.tsx b/packages/app/src/components/RivetApp.tsx index e8cb8a049..77d831891 100644 --- a/packages/app/src/components/RivetApp.tsx +++ b/packages/app/src/components/RivetApp.tsx @@ -34,6 +34,7 @@ import { HelpModal } from './HelpModal'; import { openedProjectsSortedIdsState } from '../state/savedGraphs'; import { NoProject } from './NoProject'; import { swallowPromise, syncWrapper } from '../utils/syncWrapper'; +import { allInitializeStoreFns } from '../state/storage'; const styles = css` overflow: hidden; diff --git a/packages/app/src/components/RivetAppLoader.tsx b/packages/app/src/components/RivetAppLoader.tsx new file mode 100644 index 000000000..31e97e47d --- /dev/null +++ b/packages/app/src/components/RivetAppLoader.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import { allInitializeStoreFns } from '../state/storage'; +import useAsyncEffect from 'use-async-effect'; +import { RivetApp } from './RivetApp'; + +export const RivetAppLoader = () => { + const [isLoading, setIsLoading] = useState(true); + + useAsyncEffect(async () => { + for (const initializeFn of allInitializeStoreFns) { + await initializeFn(); + } + + setIsLoading(false); + }, []); + + if (isLoading) { + return
Loading...
; + } + + return ; +}; diff --git a/packages/app/src/state/execution.ts b/packages/app/src/state/execution.ts index 507c28d11..1335a0f0d 100644 --- a/packages/app/src/state/execution.ts +++ b/packages/app/src/state/execution.ts @@ -2,9 +2,9 @@ import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { type ExecutionRecorder } from '@ironclad/rivet-core'; import { defaultExecutorState } from './settings'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; -const storage = createStorage('execution'); +const { storage } = createHybridStorage('execution'); export const remoteUploadAllowedState = atom(false); diff --git a/packages/app/src/state/graph.ts b/packages/app/src/state/graph.ts index c9129a3c6..bb98b862f 100644 --- a/packages/app/src/state/graph.ts +++ b/packages/app/src/state/graph.ts @@ -15,9 +15,9 @@ import { mapValues } from 'lodash-es'; import { projectState } from './savedGraphs'; import { pluginRefreshCounterState } from './plugins'; import { type CalculatedRevision } from '../utils/ProjectRevisionCalculator'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; -const storage = createStorage('graph'); +const { storage } = createHybridStorage('graph'); // Basic atoms export const historicalGraphState = atom(null); diff --git a/packages/app/src/state/graphBuilder.ts b/packages/app/src/state/graphBuilder.ts index 410f0d95d..584aacba6 100644 --- a/packages/app/src/state/graphBuilder.ts +++ b/packages/app/src/state/graphBuilder.ts @@ -10,10 +10,10 @@ import { type NodeGraph, } from '@ironclad/rivet-core'; import { type WireDef } from '../components/WireLayer.js'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; import { type SearchedItem, type SearchableItem } from '../hooks/useSearchProject'; -const storage = createStorage('graphBuilder'); +const { storage } = createHybridStorage('graphBuilder'); export const viewingNodeChangesState = atom(undefined); diff --git a/packages/app/src/state/savedGraphs.ts b/packages/app/src/state/savedGraphs.ts index 990c45382..5c6131fa2 100644 --- a/packages/app/src/state/savedGraphs.ts +++ b/packages/app/src/state/savedGraphs.ts @@ -14,7 +14,7 @@ import { } from '@ironclad/rivet-core'; import { blankProject } from '../utils/blankProject.js'; import { entries, values } from '../../../core/src/utils/typeSafety'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; /** Project context values stored in the IDE and not in the project file. Available in Context nodes. */ export type ProjectContext = Record< string, @@ -24,7 +24,7 @@ export type ProjectContext = Record< } >; -const storage = createStorage('project'); +const { storage } = createHybridStorage('project'); // What's the data of the last loaded project? export const projectState = atomWithStorage>( diff --git a/packages/app/src/state/settings.ts b/packages/app/src/state/settings.ts index 1adb31878..7b1279dfd 100644 --- a/packages/app/src/state/settings.ts +++ b/packages/app/src/state/settings.ts @@ -3,10 +3,10 @@ import { atomWithStorage } from 'jotai/utils'; import { type Settings } from '@ironclad/rivet-core'; import { isInTauri } from '../utils/tauri'; import { DEFAULT_CHAT_NODE_TIMEOUT } from '../../../core/src/utils/defaults'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; // Legacy storage key for recoil-persist to avoid breaking existing users' settings -const storage = createStorage('recoil-persist'); +const { storage } = createHybridStorage('recoil-persist'); export const settingsState = atomWithStorage( 'settings', diff --git a/packages/app/src/state/storage.ts b/packages/app/src/state/storage.ts index 28261631a..e712b3ac5 100644 --- a/packages/app/src/state/storage.ts +++ b/packages/app/src/state/storage.ts @@ -1,27 +1,193 @@ +import { getError } from '@ironclad/rivet-core'; import { createJSONStorage } from 'jotai/utils'; import type { SyncStorage } from 'jotai/vanilla/utils/atomWithStorage'; +import prettyBytes from 'pretty-bytes'; +import { toast } from 'react-toastify'; +import { debounce } from 'lodash-es'; -export const createStorage = (mainKey?: string): SyncStorage => { - const storage = createJSONStorage(() => localStorage); +// In-memory storage that acts as a buffer +const memoryStorage = new Map(); - if (!mainKey) { - return storage; +// Interface for the actual storage backend +interface AsyncStorageBackend { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; +} + +// IndexedDB implementation example +class IndexedDBStorage implements AsyncStorageBackend { + private dbName = 'jotai-store'; + private storeName = 'state'; + private dbPromise: Promise; + + constructor() { + this.dbPromise = this.initDB(); + } + + private initDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + db.createObjectStore(this.storeName); + }; + }); + } + + async getItem(key: string): Promise { + const db = await this.dbPromise; + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.get(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + } + + async setItem(key: string, value: string): Promise { + const db = await this.dbPromise; + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.put(value, key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async removeItem(key: string): Promise { + const db = await this.dbPromise; + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.delete(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); } +} + +// Create the hybrid storage +export const createHybridStorage = ( + mainKey?: string, + asyncStorage: AsyncStorageBackend = new IndexedDBStorage(), +): { + storage: SyncStorage; +} => { + const jsonStorage = createJSONStorage(() => localStorage); - return { + // Debounced save function to avoid too frequent writes + const debouncedSave = debounce(async (key: string, value: any) => { + try { + await asyncStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Error saving to async storage:', e); + toast.error(`Error saving to persistent storage: ${e}`); + } + }, 1000); + + const storage: SyncStorage = { getItem: (key: string, initialValue) => { - const mainObject = storage.getItem(mainKey, {}); + if (!mainKey) { + return memoryStorage.get(key) ?? initialValue; + } + const mainObject = memoryStorage.get(mainKey) ?? {}; return mainObject[key] ?? initialValue; }, setItem: (key: string, value): void => { - const mainObject = storage.getItem(mainKey, {}); - mainObject[key] = value; - storage.setItem(mainKey, mainObject); + try { + if (!mainKey) { + memoryStorage.set(key, value); + debouncedSave(key, value); + return; + } + + const mainObject = memoryStorage.get(mainKey) ?? {}; + mainObject[key] = value; + memoryStorage.set(mainKey, mainObject); + debouncedSave(mainKey, mainObject); + } catch (e) { + const err = getError(e); + toast.error(`Error setting storage item: ${err}`); + } }, removeItem: (key: string): void => { - const mainObject = storage.getItem(mainKey, {}); + if (!mainKey) { + memoryStorage.delete(key); + asyncStorage.removeItem(key).catch(console.error); + return; + } + + const mainObject = memoryStorage.get(mainKey) ?? {}; delete mainObject[key]; - storage.setItem(mainKey, mainObject); + memoryStorage.set(mainKey, mainObject); + debouncedSave(mainKey, mainObject); }, }; + + // Initialize function to load data from async storage with localStorage fallback + const initializeStore = async () => { + try { + if (!mainKey) { + // Load all individual keys + // You might need a way to know all possible keys here + return; + } + + // Try loading from IndexedDB first + const storedData = await asyncStorage.getItem(mainKey); + + if (storedData) { + const parsedData = JSON.parse(storedData); + memoryStorage.set(mainKey, parsedData); + } else { + // Fallback to localStorage if data doesn't exist in IndexedDB + try { + const localData = jsonStorage.getItem(mainKey, null); + if (localData) { + console.log('Migrating data from localStorage to IndexedDB...'); + memoryStorage.set(mainKey, localData); + // Save to async storage + await asyncStorage.setItem(mainKey, JSON.stringify(localData)); + console.log('Migration complete'); + + // Optionally, clear localStorage to free up space + // Uncomment if you want to clear after successful migration + // localStorage.removeItem(mainKey); + } + } catch (localError) { + console.error('Error reading from localStorage:', localError); + } + } + } catch (e) { + console.error('Error initializing store:', e); + toast.error(`Error loading persistent storage: ${e}`); + + // If IndexedDB fails completely, fall back to localStorage + try { + const localData = jsonStorage.getItem(mainKey!, null); + if (localData) { + memoryStorage.set(mainKey!, localData); + console.warn('Failed to load from IndexedDB, using localStorage data'); + } + } catch (localError) { + console.error('Error reading from localStorage:', localError); + } + } + }; + + allInitializeStoreFns.add(initializeStore); + + return { storage }; }; + +export const allInitializeStoreFns = new Set<() => Promise>(); diff --git a/packages/app/src/state/trivet.ts b/packages/app/src/state/trivet.ts index f40aab414..37a76e9f6 100644 --- a/packages/app/src/state/trivet.ts +++ b/packages/app/src/state/trivet.ts @@ -1,7 +1,7 @@ import { type TrivetResults, type TrivetTestSuite } from '@ironclad/trivet'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; export type TrivetState = { testSuites: TrivetTestSuite[]; @@ -11,7 +11,7 @@ export type TrivetState = { runningTests: boolean; }; -const storage = createStorage('trivet'); +const { storage } = createHybridStorage('trivet'); // Convert to persisted atom using atomWithStorage export const trivetState = atomWithStorage( diff --git a/packages/app/src/state/ui.ts b/packages/app/src/state/ui.ts index ce20620b4..35a6712f3 100644 --- a/packages/app/src/state/ui.ts +++ b/packages/app/src/state/ui.ts @@ -1,8 +1,8 @@ import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; -const storage = createStorage('ui'); +const { storage } = createHybridStorage('ui'); export const debuggerPanelOpenState = atom(false); diff --git a/packages/app/src/state/userInput.ts b/packages/app/src/state/userInput.ts index c30ebd84c..ad1032b0c 100644 --- a/packages/app/src/state/userInput.ts +++ b/packages/app/src/state/userInput.ts @@ -1,9 +1,9 @@ import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { type ArrayDataValue, type NodeId, type ProcessId, type StringDataValue } from '@ironclad/rivet-core'; -import { createStorage } from './storage.js'; +import { createHybridStorage } from './storage.js'; -const storage = createStorage('userInput'); +const { storage } = createHybridStorage('userInput'); export const userInputModalOpenState = atom(false);