From 88b9294afdd92309be8f7a786426c7531c3e2340 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 15 Jun 2026 15:25:53 +0200 Subject: [PATCH] feat(front): persist metadata store cache in IndexedDB instead of localStorage (#21586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The metadata store cache (object/field metadata, views, page layouts, command menu items, …) is persisted client-side to power **cache-first boot**: the app renders instantly from the cache, then `MinimalMetadataLoadEffect` revalidates per-collection hashes and only refetches what's stale. It was persisted to **localStorage**, which Safari/WebKit caps at **~5 MB per origin, counted in UTF-16 (2 bytes/char)** → an effective ceiling of ~2.5 M characters. Measured on the seeded demo workspace (33 objects, 612 fields): | Bucket | Safari quota (UTF-16) | |---|---| | `metadataStoreState__*` (26 keys) | **1.9 MB — 37%** | | Whole origin | **2.47 MB — 48%** | A workspace ~2.5× the demo's schema blows past 5 MB, and there is **no `QuotaExceededError` handling** — `setItem` throws and breaks the app. This is what large-workspace users on Safari have been hitting. ## Fix Move **only the metadata store** to **IndexedDB** (multi-GB, disk-based quota), keeping a **fully synchronous read path** so the ~24 consumers that read these atoms with `useAtomValue` never suspend. The auth/UI atoms (incl. the synchronously-read `tokenPair`) stay on localStorage — intentionally scoped. - **`createIndexedDbBackedJotaiStorage.ts`** — a synchronous Jotai storage facade backed by an in-memory map, hydrated once from IndexedDB at boot and written through on every set. IndexedDB access uses the **`idb-keyval`** library (by the IndexedDB spec co-author, ~0.6 KB) rather than a hand-rolled wrapper. Each cache gets its own database + BroadcastChannel (`twenty-front-`), so it's safely reusable. Swallowed errors are surfaced via `logError`. When IndexedDB is unavailable the cache stays in memory only (re-fetched each boot). - **`createAtomFamilyState`** — gains an optional `storage` param; `metadataStoreState` uses the IndexedDB-backed storage. - **`index.tsx`** — awaits hydration before mounting so atoms (`getOnInit: true`) read the persisted snapshot synchronously → cache-first boot preserved. - **No migration**: the facade does not touch localStorage at all. Pre-existing localStorage snapshots are ignored — on first boot of the new code the IndexedDB cache is empty and atoms re-fetch from the network (a one-time reconnect). Old `metadataStoreState__*` localStorage keys are left in place (cleared by the existing logout/reset cleanup); new writes only ever go to IndexedDB. - **Cross-tab sync**: the old localStorage atoms synced across tabs for free via `storage` events; the IndexedDB facade had no equivalent, so a schema change in one tab left others stale until reload. Restored by implementing the Jotai storage `subscribe` contract over a **`BroadcastChannel`** — writes broadcast to other tabs, which update their in-memory map and notify `atomWithStorage` subscribers so mounted atoms re-render live. (BroadcastChannel doesn't echo to the sender, so no feedback loop; guarded for environments without it.) ## Why a synchronous facade (not async `atomWithStorage`) Consumers use `useAtomValue` directly; an async storage would make the atoms resolve to Promises and **suspend** every reader. The in-memory facade keeps reads synchronous (zero ripple on consumers) and confines the async part to a single bulk read at boot, which the existing `MinimalMetadataGater` loader already covers. ## Tests ### Automated - Unit test (10 cases) for the storage facade: synchronous read/write, IndexedDB write-through, hydration from IndexedDB, `removeItem`/`clear`, per-cache DB namespacing, persist-failure logging, in-memory-only behaviour when IndexedDB is unavailable, distinguishing a stored `undefined` from a missing key, and cross-tab subscriber registration. - Existing metadata-store tests (`useIsLayoutCustomizationDirty`, `useDefaultHomePagePath`) still pass. - `nx typecheck twenty-front` and `nx lint:diff-with-main twenty-front` clean. ### Manual (local seeded workspace, two tabs, Playwright) Storage: - After login the metadata cache lives in **IndexedDB (24 keys, ~945 KB)** and **localStorage drops 48% → 11%** of the Safari quota (the remainder is `currentUserState` + auth, out of scope). - Reload boots from the cache (no heavy refetch). Scenarios: | Scenario | Result | |---|---| | **Sign out** | auth cleared, redirect to sign-in, no leftover localStorage, no errors | | **Sign back in** | metadata `up-to-date`, company table renders, token restored | | **Add object** (`Gadget`) | write-through to IndexedDB; survives reload via cache-first hydration | | **Add view** (`QA Cross Tab View`, TABLE) | persisted to the `views` collection (`up-to-date`) | | **Two tabs open** | second tab boots cleanly from the shared IndexedDB — no lock/crash under concurrent access | | **Cross-tab live sync** | creating an object in tab A makes it appear in tab B's open settings object list **without a reload** | Verified by design (no regression): - Runtime sign-out (`clearSession`) clears session keys and does a full `window.location.assign` reload; the metadata-clearing path (`resetJotaiStore`) is test-only, so there's no async-`clear()`-vs-sign-in race. Metadata persisting across sign-out is unchanged from the old localStorage behavior (it's schema, revalidated by hash on next login). ## Notes / follow-ups (not in this PR) - **IndexedDB query capabilities** are not used yet: the cache stores one blob per collection (as it did in localStorage), so this is still a pure key-value use (`idb-keyval`). If we later want to query individual metadata records — e.g. fields by `objectMetadataId` via an index/cursor, or partial hydration — that means record-level storage and a richer wrapper (`idb` for a thin near-native layer, or **Dexie** for a full query API + reactive `liveQuery` that could also replace the BroadcastChannel sync). - IndexedDB still has a (large) quota and Safari ITP eviction applies to both stores — the cache-first design already tolerates eviction by revalidating. - Complementary "load less" wins remain: the denormalized per-field `relation` block (~700 chars/field of pure duplication) and persisting `currentUser.workspaceMembers` (the ~0.5 MB still in localStorage). Review in cubic --- packages/twenty-front/package.json | 1 + packages/twenty-front/src/index.tsx | 13 +- .../utils/clearAllSessionLocalStorageKeys.ts | 7 +- .../states/metadataStoreState.ts | 5 +- .../storage/metadataStoreStorage.ts | 8 + .../state/jotai/types/JotaiSyncStorage.ts | 10 ++ .../createIndexedDbBackedJotaiStorage.test.ts | 153 ++++++++++++++++ .../jotai/utils/createAtomFamilyState.ts | 28 ++- .../createIndexedDbBackedJotaiStorage.ts | 163 ++++++++++++++++++ .../state/jotai/utils/isIndexedDbAvailable.ts | 7 + yarn.lock | 8 + 11 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-front/src/modules/metadata-store/storage/metadataStoreStorage.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/types/JotaiSyncStorage.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createIndexedDbBackedJotaiStorage.test.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createIndexedDbBackedJotaiStorage.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/isIndexedDbAvailable.ts 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"