Files
calendar/apps/web/modules/onboarding/store/onboarding-storage.ts
T
sean-brydonandGitHub 69e4b12f52 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
2025-10-31 09:53:14 +00:00

133 lines
3.8 KiB
TypeScript

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<IDBDatabase> | null = null;
function getDB(): Promise<IDBDatabase> {
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<string | null> {
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<void> {
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<void> {
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);