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);