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"