diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index 35cae84917..807084f771 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -94,6 +94,7 @@
"graphql": "16.8.1",
"graphql-sse": "^2.5.4",
"graphql-tag": "^2.12.6",
+ "idb-keyval": "^6.2.5",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"jotai": "^2.17.1",
diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx
index ca1d2610d2..16ce2ee2cf 100644
--- a/packages/twenty-front/src/index.tsx
+++ b/packages/twenty-front/src/index.tsx
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client';
import { App } from '@/app/components/App';
import { migrateTokenPairCookieToLocalStorage } from '@/auth/utils/migrateTokenPairCookieToLocalStorage';
+import { hydrateMetadataStore } from '@/metadata-store/storage/metadataStoreStorage';
import 'react-loading-skeleton/dist/skeleton.css';
import 'twenty-ui-deprecated/style.css';
import 'twenty-ui-deprecated/theme-light.css';
@@ -15,8 +16,12 @@ import './index.css';
// legacy cookie to localStorage (legacy cookie has a 180-day expiry).
migrateTokenPairCookieToLocalStorage();
-const root = ReactDOM.createRoot(
- document.getElementById('root') ?? document.body,
-);
+const renderApp = () => {
+ const root = ReactDOM.createRoot(
+ document.getElementById('root') ?? document.body,
+ );
-root.render();
+ root.render();
+};
+
+hydrateMetadataStore().then(renderApp, renderApp);
diff --git a/packages/twenty-front/src/modules/auth/utils/clearAllSessionLocalStorageKeys.ts b/packages/twenty-front/src/modules/auth/utils/clearAllSessionLocalStorageKeys.ts
index a409f16f9c..d9b4b7f554 100644
--- a/packages/twenty-front/src/modules/auth/utils/clearAllSessionLocalStorageKeys.ts
+++ b/packages/twenty-front/src/modules/auth/utils/clearAllSessionLocalStorageKeys.ts
@@ -1,18 +1,19 @@
import { safeRemoveLocalStorageItems } from '@/auth/utils/safeRemoveLocalStorageItems';
import {
ALL_METADATA_ENTITY_KEYS,
+ METADATA_STORE_KEY_PREFIX,
type MetadataEntityKey,
} from '@/metadata-store/states/metadataStoreState';
+import { clearMetadataStoreStorage } from '@/metadata-store/storage/metadataStoreStorage';
import { clearSessionLocalStorageKeys } from './clearSessionLocalStorageKeys';
-const METADATA_STORE_PREFIX = 'metadataStoreState__';
-
const getMetadataStoreKeys = (): string[] =>
ALL_METADATA_ENTITY_KEYS.map(
- (key: MetadataEntityKey) => `${METADATA_STORE_PREFIX}${key}`,
+ (key: MetadataEntityKey) => `${METADATA_STORE_KEY_PREFIX}${key}`,
);
export const clearAllSessionLocalStorageKeys = () => {
clearSessionLocalStorageKeys();
+ void clearMetadataStoreStorage();
safeRemoveLocalStorageItems(getMetadataStoreKeys());
};
diff --git a/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
index deb066519f..ed59aa4144 100644
--- a/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
+++ b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
@@ -1,3 +1,4 @@
+import { metadataStoreStorage } from '@/metadata-store/storage/metadataStoreStorage';
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export type MetadataEntityStoreStatus =
@@ -51,12 +52,14 @@ const METADATA_STORE_ITEM_INITIAL_VALUE: MetadataStoreItem = {
status: 'empty',
};
+export const METADATA_STORE_KEY_PREFIX = 'metadataStoreState__';
+
export const metadataStoreState = createAtomFamilyState<
MetadataStoreItem,
MetadataEntityKey
>({
key: 'metadataStoreState',
defaultValue: METADATA_STORE_ITEM_INITIAL_VALUE,
- useLocalStorage: true,
+ storage: metadataStoreStorage,
localStorageOptions: { getOnInit: true },
});
diff --git a/packages/twenty-front/src/modules/metadata-store/storage/metadataStoreStorage.ts b/packages/twenty-front/src/modules/metadata-store/storage/metadataStoreStorage.ts
new file mode 100644
index 0000000000..32c8657a4b
--- /dev/null
+++ b/packages/twenty-front/src/modules/metadata-store/storage/metadataStoreStorage.ts
@@ -0,0 +1,8 @@
+import { type MetadataStoreItem } from '@/metadata-store/states/metadataStoreState';
+import { createIndexedDbBackedJotaiStorage } from '@/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage';
+
+export const {
+ storage: metadataStoreStorage,
+ hydrate: hydrateMetadataStore,
+ clear: clearMetadataStoreStorage,
+} = createIndexedDbBackedJotaiStorage('metadata-store');
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/JotaiSyncStorage.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/JotaiSyncStorage.ts
new file mode 100644
index 0000000000..f69cd6b278
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/types/JotaiSyncStorage.ts
@@ -0,0 +1,10 @@
+export type JotaiSyncStorage = {
+ getItem: (key: string, initialValue: ValueType) => ValueType;
+ setItem: (key: string, newValue: ValueType) => void;
+ removeItem: (key: string) => void;
+ subscribe?: (
+ key: string,
+ callback: (value: ValueType) => void,
+ initialValue: ValueType,
+ ) => () => void;
+};
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createIndexedDbBackedJotaiStorage.test.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createIndexedDbBackedJotaiStorage.test.ts
new file mode 100644
index 0000000000..50d0f84aca
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createIndexedDbBackedJotaiStorage.test.ts
@@ -0,0 +1,153 @@
+import { clear, createStore, del, entries, set } from 'idb-keyval';
+
+import { createIndexedDbBackedJotaiStorage } from '@/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage';
+import { isIndexedDbAvailable } from '@/ui/utilities/state/jotai/utils/isIndexedDbAvailable';
+import { logError } from '~/utils/logError';
+
+jest.mock('idb-keyval', () => ({
+ createStore: jest.fn(() => ({ store: 'mock' })),
+ set: jest.fn(() => Promise.resolve()),
+ del: jest.fn(() => Promise.resolve()),
+ clear: jest.fn(() => Promise.resolve()),
+ entries: jest.fn(() => Promise.resolve([])),
+}));
+jest.mock('@/ui/utilities/state/jotai/utils/isIndexedDbAvailable');
+jest.mock('~/utils/logError');
+
+const mockedSet = jest.mocked(set);
+const mockedDel = jest.mocked(del);
+const mockedClear = jest.mocked(clear);
+const mockedEntries = jest.mocked(entries);
+const mockedCreateStore = jest.mocked(createStore);
+const mockedIsIndexedDbAvailable = jest.mocked(isIndexedDbAvailable);
+const mockedLogError = jest.mocked(logError);
+
+const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+type Item = { value: number };
+
+const INITIAL: Item = { value: 0 };
+
+describe('createIndexedDbBackedJotaiStorage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorage.clear();
+ mockedIsIndexedDbAvailable.mockReturnValue(true);
+ mockedEntries.mockResolvedValue([]);
+ });
+
+ it('should use a database namespaced to the cache name', () => {
+ createIndexedDbBackedJotaiStorage- ('metadata-store');
+
+ expect(mockedCreateStore).toHaveBeenCalledWith(
+ 'twenty-front-metadata-store',
+ 'keyval',
+ );
+ });
+
+ it('should read synchronously from the in-memory map', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ expect(storage.getItem('k', INITIAL)).toBe(INITIAL);
+
+ storage.setItem('k', { value: 1 });
+
+ expect(storage.getItem('k', INITIAL)).toEqual({ value: 1 });
+ });
+
+ it('should distinguish a stored undefined value from a missing key', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage(
+ 'test',
+ );
+
+ expect(storage.getItem('k', 0)).toBe(0);
+
+ storage.setItem('k', undefined);
+
+ expect(storage.getItem('k', 0)).toBeUndefined();
+ });
+
+ it('should write through to IndexedDB on set', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ storage.setItem('k', { value: 2 });
+
+ expect(mockedSet).toHaveBeenCalledWith(
+ 'k',
+ { value: 2 },
+ expect.anything(),
+ );
+ });
+
+ it('should hydrate the in-memory map from IndexedDB', async () => {
+ mockedEntries.mockResolvedValue([['k', { value: 7 }]]);
+
+ const { storage, hydrate } =
+ createIndexedDbBackedJotaiStorage
- ('test');
+
+ await hydrate();
+
+ expect(storage.getItem('k', INITIAL)).toEqual({ value: 7 });
+ });
+
+ it('should delete from the map and IndexedDB on removeItem', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ storage.setItem('k', { value: 1 });
+ storage.removeItem('k');
+
+ expect(storage.getItem('k', INITIAL)).toBe(INITIAL);
+ expect(mockedDel).toHaveBeenCalledWith('k', expect.anything());
+ });
+
+ it('should clear the map and IndexedDB', async () => {
+ const { storage, clear: clearStorage } =
+ createIndexedDbBackedJotaiStorage
- ('test');
+
+ storage.setItem('k', { value: 1 });
+ await clearStorage();
+
+ expect(storage.getItem('k', INITIAL)).toBe(INITIAL);
+ expect(mockedClear).toHaveBeenCalled();
+ });
+
+ it('should register and unregister cross-tab subscribers cleanly', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ const callback = jest.fn();
+ const unsubscribe = storage.subscribe?.('k', callback, INITIAL);
+
+ expect(typeof unsubscribe).toBe('function');
+ expect(() => unsubscribe?.()).not.toThrow();
+ });
+
+ it('should keep the value in memory and log when a persist fails', async () => {
+ mockedSet.mockRejectedValueOnce(new Error('write failed'));
+
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ expect(() => storage.setItem('k', { value: 1 })).not.toThrow();
+ expect(storage.getItem('k', INITIAL)).toEqual({ value: 1 });
+
+ await flushMicrotasks();
+
+ expect(mockedLogError).toHaveBeenCalled();
+ });
+
+ describe('when IndexedDB is unavailable', () => {
+ beforeEach(() => {
+ mockedIsIndexedDbAvailable.mockReturnValue(false);
+ });
+
+ it('should keep values in memory only, without persisting anywhere', () => {
+ const { storage } = createIndexedDbBackedJotaiStorage
- ('test');
+
+ storage.setItem('k', { value: 5 });
+
+ expect(storage.getItem('k', INITIAL)).toEqual({ value: 5 });
+ expect(mockedCreateStore).not.toHaveBeenCalled();
+ expect(mockedSet).not.toHaveBeenCalled();
+ expect(localStorage.getItem('k')).toBeNull();
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilyState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilyState.ts
index cecd0c76b1..a677d6d9c1 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilyState.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilyState.ts
@@ -1,18 +1,22 @@
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
+import { isDefined } from 'twenty-shared/utils';
import { type FamilyState } from '@/ui/utilities/state/jotai/types/FamilyState';
+import { type JotaiSyncStorage } from '@/ui/utilities/state/jotai/types/JotaiSyncStorage';
export const createAtomFamilyState = ({
key,
defaultValue,
useLocalStorage = false,
localStorageOptions,
+ storage,
}: {
key: string;
defaultValue: ValueType;
useLocalStorage?: boolean;
localStorageOptions?: { getOnInit?: boolean };
+ storage?: JotaiSyncStorage;
}): FamilyState => {
const atomCache = new Map<
string,
@@ -32,14 +36,30 @@ export const createAtomFamilyState = ({
}
const atomKey = `${key}__${cacheKey}`;
- const baseAtom = useLocalStorage
- ? atomWithStorage(
+
+ const buildBaseAtom = () => {
+ if (isDefined(storage)) {
+ return atomWithStorage(
+ atomKey,
+ defaultValue,
+ storage,
+ localStorageOptions ?? { getOnInit: true },
+ );
+ }
+
+ if (useLocalStorage) {
+ return atomWithStorage(
atomKey,
defaultValue,
undefined,
localStorageOptions ?? undefined,
- )
- : atom(defaultValue);
+ );
+ }
+
+ return atom(defaultValue);
+ };
+
+ const baseAtom = buildBaseAtom();
baseAtom.debugLabel = atomKey;
atomCache.set(cacheKey, baseAtom);
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage.ts
new file mode 100644
index 0000000000..d70bdfefbf
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage.ts
@@ -0,0 +1,163 @@
+import * as idb from 'idb-keyval';
+import { isDefined } from 'twenty-shared/utils';
+
+import { type JotaiSyncStorage } from '@/ui/utilities/state/jotai/types/JotaiSyncStorage';
+import { isIndexedDbAvailable } from '@/ui/utilities/state/jotai/utils/isIndexedDbAvailable';
+import { logError } from '~/utils/logError';
+
+const INDEXED_DB_STORE_NAME = 'keyval';
+
+type CrossTabMessage =
+ | { type: 'set'; key: string; value: ValueType }
+ | { type: 'remove'; key: string };
+
+type CrossTabSubscriber = {
+ callback: (value: ValueType) => void;
+ initialValue: ValueType;
+};
+
+type IndexedDbBackedJotaiStorage = {
+ storage: JotaiSyncStorage;
+ hydrate: () => Promise;
+ clear: () => Promise;
+};
+
+const createIndexedDbStore = (cacheName: string): idb.UseStore | undefined => {
+ if (!isIndexedDbAvailable()) {
+ return undefined;
+ }
+
+ return idb.createStore(`twenty-front-${cacheName}`, INDEXED_DB_STORE_NAME);
+};
+
+const createCrossTabChannel = (cacheName: string): BroadcastChannel | null => {
+ if (typeof BroadcastChannel === 'undefined') {
+ return null;
+ }
+
+ try {
+ return new BroadcastChannel(`twenty-front-${cacheName}-sync`);
+ } catch (error) {
+ logError(error);
+ return null;
+ }
+};
+
+export const createIndexedDbBackedJotaiStorage = (
+ cacheName: string,
+): IndexedDbBackedJotaiStorage => {
+ const memoryMap = new Map();
+ const idbStore = createIndexedDbStore(cacheName);
+ const broadcastChannel = createCrossTabChannel(cacheName);
+ const subscribers = new Map>>();
+ let isHydrated = false;
+
+ const persist = (operation: Promise): void => {
+ void operation.catch(logError);
+ };
+
+ if (broadcastChannel !== null) {
+ broadcastChannel.onmessage = (
+ event: MessageEvent>,
+ ) => {
+ const message = event.data;
+
+ if (message.type === 'set') {
+ memoryMap.set(message.key, message.value);
+ } else {
+ memoryMap.delete(message.key);
+ }
+
+ const keySubscribers = subscribers.get(message.key);
+
+ if (isDefined(keySubscribers)) {
+ for (const subscriber of keySubscribers) {
+ subscriber.callback(
+ message.type === 'set' ? message.value : subscriber.initialValue,
+ );
+ }
+ }
+ };
+ }
+
+ const broadcast = (message: CrossTabMessage): void => {
+ try {
+ broadcastChannel?.postMessage(message);
+ } catch (error) {
+ logError(error);
+ }
+ };
+
+ const storage: JotaiSyncStorage = {
+ getItem: (key, initialValue) =>
+ memoryMap.has(key) ? (memoryMap.get(key) as ValueType) : initialValue,
+ setItem: (key, newValue) => {
+ memoryMap.set(key, newValue);
+ broadcast({ type: 'set', key, value: newValue });
+
+ if (isDefined(idbStore)) {
+ persist(idb.set(key, newValue, idbStore));
+ }
+ },
+ removeItem: (key) => {
+ memoryMap.delete(key);
+ broadcast({ type: 'remove', key });
+
+ if (isDefined(idbStore)) {
+ persist(idb.del(key, idbStore));
+ }
+ },
+ subscribe: (key, callback, initialValue) => {
+ const keySubscribers = subscribers.get(key) ?? new Set();
+ const subscriber: CrossTabSubscriber = {
+ callback,
+ initialValue,
+ };
+ keySubscribers.add(subscriber);
+ subscribers.set(key, keySubscribers);
+
+ return () => {
+ keySubscribers.delete(subscriber);
+
+ if (keySubscribers.size === 0) {
+ subscribers.delete(key);
+ }
+ };
+ },
+ };
+
+ const hydrate = async (): Promise => {
+ if (isHydrated || !isDefined(idbStore)) {
+ isHydrated = true;
+ return;
+ }
+
+ try {
+ const persistedEntries = await idb.entries(idbStore);
+
+ for (const [key, value] of persistedEntries) {
+ memoryMap.set(key, value);
+ }
+ } catch (error) {
+ logError(error);
+ }
+
+ isHydrated = true;
+ };
+
+ const clear = async (): Promise => {
+ memoryMap.clear();
+
+ if (!isDefined(idbStore)) {
+ return;
+ }
+
+ try {
+ await idb.clear(idbStore);
+ } catch (error) {
+ logError(error);
+ }
+ };
+
+ return { storage, hydrate, clear };
+};
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/isIndexedDbAvailable.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/isIndexedDbAvailable.ts
new file mode 100644
index 0000000000..aaf12f474b
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/isIndexedDbAvailable.ts
@@ -0,0 +1,7 @@
+export const isIndexedDbAvailable = (): boolean => {
+ try {
+ return typeof indexedDB !== 'undefined' && indexedDB !== null;
+ } catch {
+ return false;
+ }
+};
diff --git a/yarn.lock b/yarn.lock
index ccba2ab4b8..fceaed3ebc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -36608,6 +36608,13 @@ __metadata:
languageName: node
linkType: hard
+"idb-keyval@npm:^6.2.5":
+ version: 6.2.5
+ resolution: "idb-keyval@npm:6.2.5"
+ checksum: 10c0/58055106e2447cd4a91c62f1c6d4122d8e6d141c86c6e79860dbb6af0c6462bc9520e84c1d56dc9e5fb66c817dffd04b2a2432efada1273b89db955fc49d374b
+ languageName: node
+ linkType: hard
+
"identifier-regex@npm:^1.0.0":
version: 1.0.1
resolution: "identifier-regex@npm:1.0.1"
@@ -53509,6 +53516,7 @@ __metadata:
graphql: "npm:16.8.1"
graphql-sse: "npm:^2.5.4"
graphql-tag: "npm:^2.12.6"
+ idb-keyval: "npm:^6.2.5"
immer: "npm:^10.1.1"
input-otp: "npm:^1.4.2"
jest: "npm:29.7.0"