From 69e4b12f52197778677c04fc78f4457d9b93ca8e Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 31 Oct 2025 09:53:14 +0000 Subject: [PATCH] fix: Use index DB instead of local storage for onboarding (#24798) ## What does this PR do? Implements an IndexedDB storage adapter for the onboarding store to replace localStorage. This allows storing much larger data (up to 50% of available disk space) compared to localStorage's limited quota, making it suitable for storing base64-encoded images like logos and banners during onboarding. The implementation includes a fallback to localStorage if IndexedDB is unavailable. Sometimes when you uploaded a large image or banner you get this error. Even if its within the bounds of 10mb (which is what we allow) ![image.png](https://app.graphite.dev/user-attachments/assets/f9fad1b6-5c8a-438b-8948-ea299c2ce832.png) ## Visual Demo (For contributors especially) N/A - This is an infrastructure change with no visual component. ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code. - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - Test the onboarding flow and verify that data persists between page refreshes - Test uploading large images during onboarding to ensure they're properly stored - Test in browsers with IndexedDB disabled to verify the localStorage fallback works correctly - Verify that the onboarding state is properly maintained when navigating through the onboarding steps ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings --- .../onboarding/store/onboarding-storage.ts | 132 ++++++++++++++++++ .../onboarding/store/onboarding-store.ts | 5 +- 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/web/modules/onboarding/store/onboarding-storage.ts diff --git a/apps/web/modules/onboarding/store/onboarding-storage.ts b/apps/web/modules/onboarding/store/onboarding-storage.ts new file mode 100644 index 0000000000..807b779847 --- /dev/null +++ b/apps/web/modules/onboarding/store/onboarding-storage.ts @@ -0,0 +1,132 @@ +import { createJSONStorage, type StateStorage } from "zustand/middleware"; + +/** + * IndexedDB storage adapter for onboarding store. + * Supports much larger storage limits than localStorage (typically 50% of available disk space), + * making it suitable for storing large base64-encoded images (logos, banners, etc.). + * Falls back to localStorage if IndexedDB is not available. + */ +const DB_NAME = "cal-onboarding-idb"; +const DB_VERSION = 1; +const STORE_NAME = "keyval"; + +// Cache database connection to avoid opening/closing repeatedly +let dbPromise: Promise | null = null; + +function getDB(): Promise { + if (dbPromise) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + if (typeof window === "undefined" || !window.indexedDB) { + reject(new Error("IndexedDB is not available")); + return; + } + + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + const db = request.result; + // Reset promise on close so we can reconnect if needed + db.onclose = () => { + dbPromise = null; + }; + resolve(db); + }; + }); + + return dbPromise; +} + +async function getItem(key: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result ?? null); + }); + } catch { + // Fallback to localStorage if IndexedDB fails + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + } +} + +async function setItem(key: string, value: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(value, key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } catch { + // Fallback to localStorage if IndexedDB fails (will throw if quota exceeded) + try { + window.localStorage.setItem(key, value); + } catch { + // Storage quota exceeded even in fallback - not much we can do + } + } +} + +async function removeItem(key: string): Promise { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } catch { + // Fallback to localStorage if IndexedDB fails + try { + window.localStorage.removeItem(key); + } catch { + // Ignore errors on remove + } + } +} + +/** + * IndexedDB storage adapter that implements StateStorage interface. + * This provides much larger storage capacity than localStorage, making it suitable for + * storing large base64-encoded images (logos, banners, etc.). + */ +const indexedDBStateStorage: StateStorage = { + getItem, + setItem, + removeItem, +}; + +/** + * IndexedDB storage adapter that implements the PersistStorage interface for zustand persist. + * Uses createJSONStorage to handle JSON serialization/deserialization automatically. + */ +export const onboardingIndexedDBStorage = createJSONStorage(() => indexedDBStateStorage); diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts index 46a2d38d90..2da6bb0236 100644 --- a/apps/web/modules/onboarding/store/onboarding-store.ts +++ b/apps/web/modules/onboarding/store/onboarding-store.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { onboardingIndexedDBStorage } from "./onboarding-storage"; + export type PlanType = "personal" | "team" | "organization"; export type InviteRole = "MEMBER" | "ADMIN"; @@ -160,7 +162,8 @@ export const useOnboardingStore = create()( resetOnboarding: () => set(initialState), }), { - name: "cal-onboarding-storage", // localStorage key + name: "cal-onboarding-storage", // Storage key + storage: onboardingIndexedDBStorage, // Use IndexedDB instead of localStorage for larger capacity // Optional: Only persist certain fields partialize: (state) => ({ selectedPlan: state.selectedPlan,